From eaa782b182f30cd792998908183e75f21992c810 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Tue, 7 Jul 2026 19:04:53 +0200 Subject: [PATCH 001/113] WPB-26704: add full `conversation` to `POST /meetings` (create) and `PUT /meetings/{domain}/{id}` (#5301) --- changelog.d/1-api-changes/WPB-26704 | 1 + integration/test/Test/Meetings.hs | 49 ++- libs/wire-api/src/Wire/API/Meeting.hs | 50 ++- .../Wire/API/Routes/Public/Galley/Meetings.hs | 8 +- .../src/Wire/MeetingsSubsystem.hs | 5 +- .../src/Wire/MeetingsSubsystem/Interpreter.hs | 63 +++- .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 303 ++++++++++-------- services/galley/src/Galley/API/Meetings.hs | 8 +- 8 files changed, 294 insertions(+), 193 deletions(-) create mode 100644 changelog.d/1-api-changes/WPB-26704 diff --git a/changelog.d/1-api-changes/WPB-26704 b/changelog.d/1-api-changes/WPB-26704 new file mode 100644 index 00000000000..ee9b0ef5cd7 --- /dev/null +++ b/changelog.d/1-api-changes/WPB-26704 @@ -0,0 +1 @@ +`POST /meetings` (create) and `PUT /meetings/{domain}/{id}` (update) now return a full `conversation` object alongside the existing meeting fields. The legacy `qualified_conversation` field is retained for backward compatibility. diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs index 6f60057929c..1cfc518d3c8 100644 --- a/integration/test/Test/Meetings.hs +++ b/integration/test/Test/Meetings.hs @@ -17,23 +17,6 @@ import Testlib.Prelude import Text.Regex.TDFA ((=~)) import UnliftIO.Concurrent (threadDelay) --- Helper to extract meetingId and domain from a meeting JSON object -getMeetingIdAndDomain :: (HasCallStack) => Value -> App (String, String) -getMeetingIdAndDomain meeting = do - meetingId <- meeting %. "qualified_id" %. "id" >>= asString - domain <- meeting %. "qualified_id" %. "domain" >>= asString - pure (meetingId, domain) - --- Helper to create a default new meeting JSON object -defaultMeetingJson :: String -> UTCTime -> UTCTime -> [String] -> Value -defaultMeetingJson title startTime endTime invitedEmails = - object - [ "title" .= title, - "start_time" .= startTime, - "end_time" .= endTime, - "invited_emails" .= invitedEmails - ] - testMeetingCreate :: (HasCallStack) => App () testMeetingCreate = do (owner, _tid, _members) <- createTeam OwnDomain 1 @@ -54,6 +37,9 @@ testMeetingCreate = do meeting %. "qualified_creator" %. "id" `shouldMatch` ownerId meeting %. "invited_emails" `shouldMatch` (["alice@example.com", "bob@example.com"] :: [String]) + -- The full conversation is returned alongside the legacy field + assertConversationMatchesLegacy meeting + -- Verify fetching the meeting (meetingId, domain) <- getMeetingIdAndDomain meeting r2 <- getMeeting owner domain meetingId @@ -93,6 +79,32 @@ testMeetingMLSAddParticipant = do (length <$> (res.json %. "members.others" & asList)) `shouldMatchInt` 1 res.json %. "members.others.0.qualified_id" `shouldMatch` objQidObject bob +-- | Helper to extract meetingId and domain from a meeting JSON object +getMeetingIdAndDomain :: (HasCallStack) => Value -> App (String, String) +getMeetingIdAndDomain meeting = do + meetingId <- meeting %. "qualified_id" %. "id" >>= asString + domain <- meeting %. "qualified_id" %. "domain" >>= asString + pure (meetingId, domain) + +-- | On create/update responses, the full @conversation@ object is returned +-- alongside the legacy @qualified_conversation@ field. This asserts that both +-- refer to the same conversation. +assertConversationMatchesLegacy :: (HasCallStack) => Value -> App () +assertConversationMatchesLegacy meeting = do + convId <- meeting %. "conversation" %. "qualified_id" + legacyConvId <- meeting %. "qualified_conversation" + convId `shouldMatch` legacyConvId + +-- | Helper to create a default new meeting JSON object +defaultMeetingJson :: String -> UTCTime -> UTCTime -> [String] -> Value +defaultMeetingJson title startTime endTime invitedEmails = + object + [ "title" .= title, + "start_time" .= startTime, + "end_time" .= endTime, + "invited_emails" .= invitedEmails + ] + testMeetingGetNotFound :: (HasCallStack) => App () testMeetingGetNotFound = do (owner, _tid, _members) <- createTeam OwnDomain 1 @@ -201,6 +213,9 @@ testMeetingRecurrence = do recurrence' %. "frequency" `shouldMatch` "weekly" recurrence' %. "interval" `shouldMatchInt` 2 + -- The full conversation is still returned on update + assertConversationMatchesLegacy updated + testMeetingUpdateNotFound :: (HasCallStack) => App () testMeetingUpdateNotFound = do (owner, _tid, _members) <- createTeam OwnDomain 1 diff --git a/libs/wire-api/src/Wire/API/Meeting.hs b/libs/wire-api/src/Wire/API/Meeting.hs index 44e2c956554..2a2a4cff8d0 100644 --- a/libs/wire-api/src/Wire/API/Meeting.hs +++ b/libs/wire-api/src/Wire/API/Meeting.hs @@ -28,6 +28,7 @@ import Data.Schema import Data.Time.Clock import Deriving.Aeson import Imports +import Wire.API.Conversation (Conversation, GroupConvType) import Wire.API.PostgresMarshall (PostgresMarshall (..), PostgresUnmarshall (..)) import Wire.API.User.Identity (EmailAddress) import Wire.Arbitrary (Arbitrary, GenericUniform (..)) @@ -50,21 +51,44 @@ data Meeting = Meeting deriving (ToJSON, FromJSON, S.ToSchema) via (Schema Meeting) deriving (Arbitrary) via (GenericUniform Meeting) +meetingObject :: ObjectSchema SwaggerDoc Meeting +meetingObject = + Meeting + <$> (.id) .= field "qualified_id" schema + <*> (.title) .= field "title" schema + <*> (.creator) .= field "qualified_creator" schema + <*> (.startTime) .= field "start_time" utcTimeSchema + <*> (.endTime) .= field "end_time" utcTimeSchema + <*> (.recurrence) .= maybe_ (optField "recurrence" schema) + <*> (.conversationId) .= field "qualified_conversation" schema + <*> (.invitedEmails) .= field "invited_emails" (array schema) + <*> (.trial) .= field "trial" schema + <*> (.createdAt) .= field "created_at" utcTimeSchema + <*> (.updatedAt) .= field "updated_at" utcTimeSchema + instance ToSchema Meeting where schema = - objectWithDocModifier (description ?~ "A scheduled meeting") $ - Meeting - <$> (.id) .= field "qualified_id" schema - <*> (.title) .= field "title" schema - <*> (.creator) .= field "qualified_creator" schema - <*> (.startTime) .= field "start_time" utcTimeSchema - <*> (.endTime) .= field "end_time" utcTimeSchema - <*> (.recurrence) .= maybe_ (optField "recurrence" schema) - <*> (.conversationId) .= field "qualified_conversation" schema - <*> (.invitedEmails) .= field "invited_emails" (array schema) - <*> (.trial) .= field "trial" schema - <*> (.createdAt) .= field "created_at" utcTimeSchema - <*> (.updatedAt) .= field "updated_at" utcTimeSchema + objectWithDocModifier (description ?~ "A scheduled meeting") meetingObject + +-- | A 'Meeting' extended with the full 'Conversation' associated with it, as +-- returned when creating or updating a meeting. The underlying 'Meeting' is +-- reused (no field duplication) and flattened into the JSON object in the +-- 'ToSchema' instance, so that the legacy @qualified_conversation@ field and +-- the full @conversation@ are returned alongside the meeting fields. +data MeetingWithConversation = MeetingWithConversation + { meeting :: Meeting, + conversation :: Conversation GroupConvType + } + deriving stock (Eq, Show, Generic) + deriving (ToJSON, FromJSON, S.ToSchema) via (Schema MeetingWithConversation) + deriving (Arbitrary) via (GenericUniform MeetingWithConversation) + +instance ToSchema MeetingWithConversation where + schema = + objectWithDocModifier (description ?~ "A scheduled meeting with its associated conversation") $ + MeetingWithConversation + <$> (.meeting) .= meetingObject + <*> (.conversation) .= field "conversation" schema -- | Request to create a new meeting data NewMeeting = NewMeeting diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs index 418b6c803ab..2349a5452d7 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs @@ -41,8 +41,8 @@ type MeetingsAPI = :> MultiVerb 'POST '[JSON] - '[Respond 201 "Meeting created" Meeting] - Meeting + '[Respond 201 "Meeting created" MeetingWithConversation] + MeetingWithConversation ) :<|> Named "update-meeting" @@ -59,8 +59,8 @@ type MeetingsAPI = :> MultiVerb 'PUT '[JSON] - '[Respond 200 "Meeting updated" Meeting] - Meeting + '[Respond 200 "Meeting updated" MeetingWithConversation] + MeetingWithConversation ) :<|> Named "delete-meeting" diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs index b74afeac204..ac3bcced59f 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs @@ -26,18 +26,17 @@ import Imports import Polysemy import Wire.API.Meeting import Wire.API.User.EmailAddress (EmailAddress) -import Wire.StoredConversation (StoredConversation) data MeetingsSubsystem m a where CreateMeeting :: Local UserId -> NewMeeting -> - MeetingsSubsystem m (Meeting, StoredConversation) + MeetingsSubsystem m MeetingWithConversation UpdateMeeting :: Local UserId -> Qualified MeetingId -> UpdateMeeting -> - MeetingsSubsystem m (Maybe Meeting) + MeetingsSubsystem m (Maybe MeetingWithConversation) DeleteMeeting :: Local UserId -> ConnId -> diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs index be069e07c04..44acf71c198 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs @@ -22,6 +22,7 @@ module Wire.MeetingsSubsystem.Interpreter where import Control.Monad.Trans.Maybe (MaybeT (MaybeT, runMaybeT)) +import Data.ByteString.Conversion (toByteString') import Data.Default (def) import Data.Domain (Domain) import Data.Id @@ -34,6 +35,9 @@ import Imports import Polysemy import Polysemy.Error import Polysemy.Input (Input) +import Polysemy.TinyLog (TinyLog) +import Polysemy.TinyLog qualified as TinyLog +import System.Logger qualified as Log import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Role (roleNameWireAdmin) import Wire.API.Meeting qualified as API @@ -80,6 +84,7 @@ interpretMeetingsSubsystem :: Member TeamSubsystem r, Member FeaturesConfigSubsystem r, Member Now r, + Member TinyLog r, Member (Error MeetingError) r, Member (Input (Local ())) r ) => @@ -114,7 +119,7 @@ createMeetingImpl :: ) => Local UserId -> API.NewMeeting -> - Sem r (API.Meeting, StoredConversation) + Sem r API.MeetingWithConversation createMeetingImpl zUser newMeeting = do -- Look up user's team once and reuse for both checks conversationTeamId <- TeamSubsystem.internalGetOneUserTeam (tUnqualified zUser) @@ -168,16 +173,14 @@ createMeetingImpl zUser newMeeting = do newMeeting.invitedEmails trial - -- Return created meeting - pure - ( storedMeetingToMeeting (tDomain zUser) storedMeeting, - storedConv - ) + pure $ storedMeetingToMeetingWithConversation zUser storedConv storedMeeting updateMeetingImpl :: ( Member Store.MeetingsStore r, + Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member TinyLog r, Member (Error MeetingError) r, Member Now r ) => @@ -185,7 +188,7 @@ updateMeetingImpl :: Qualified MeetingId -> API.UpdateMeeting -> NominalDiffTime -> - Sem r (Maybe API.Meeting) + Sem r (Maybe API.MeetingWithConversation) updateMeetingImpl zUser meetingId update validityPeriod = do maybeTeamId <- TeamSubsystem.internalGetOneUserTeam (tUnqualified zUser) checkMeetingsEnabled maybeTeamId @@ -211,13 +214,15 @@ updateMeetingImpl zUser meetingId update validityPeriod = do update.startTime update.endTime update.recurrence - pure $ storedMeetingToMeeting (tDomain zUser) updatedMeeting + conv <- MaybeT $ getMeetingConversationOrFail meetingId updatedMeeting.conversationId + pure $ storedMeetingToMeetingWithConversation zUser conv updatedMeeting deleteMeetingImpl :: ( Member Store.MeetingsStore r, Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member TinyLog r, Member (Error MeetingError) r, Member Now r ) => @@ -239,7 +244,7 @@ deleteMeetingImpl zUser connId meetingId validityPeriod = do guard $ meeting.creator == tUnqualified zUser let convId = meeting.conversationId lConvId = qualifyAs zUser convId - conv <- MaybeT $ ConversationSubsystem.internalGetConversation convId + conv <- MaybeT $ getMeetingConversationOrFail meetingId convId when (conv.metadata.cnvmGroupConvType == Just MeetingConversation) $ lift $ void $ @@ -279,6 +284,28 @@ getMeetingImpl zUser meetingId validityPeriod = do void $ MaybeT $ ConversationSubsystem.internalGetLocalMember convId (tUnqualified zUser) pure $ storedMeetingToMeeting (tDomain zUser) storedMeeting -- User is a member, authorized +-- | Look up the 'StoredConversation' associated with a meeting. When the +-- conversation cannot be found (a data-integrity anomaly), a warning is logged +-- before failing: otherwise the missing conversation is indistinguishable from +-- a missing meeting for callers. +getMeetingConversationOrFail :: + ( Member ConversationSubsystem r, + Member TinyLog r + ) => + Qualified MeetingId -> + ConvId -> + Sem r (Maybe StoredConversation) +getMeetingConversationOrFail meetingId convId = do + mConv <- ConversationSubsystem.internalGetConversation convId + case mConv of + Just conv -> pure (Just conv) + Nothing -> do + TinyLog.warn $ + Log.msg ("conversation not found for meeting" :: ByteString) + . Log.field "conversationId" (toByteString' convId) + . Log.field "meetingId" (toByteString' (qUnqualified meetingId)) + pure Nothing + -- Helper function to convert StoredMeeting to API.Meeting storedMeetingToMeeting :: Domain -> Store.StoredMeeting -> API.Meeting storedMeetingToMeeting domain sm = @@ -296,6 +323,24 @@ storedMeetingToMeeting domain sm = API.updatedAt = sm.updatedAt } +-- | Like 'storedMeetingToMeeting', but additionally carries the full +-- 'API.Conversation' associated with the meeting. +-- +-- The local user's domain ('tDomain lUser') is used to qualify the meeting, +-- its creator and its conversation: meetings are not federated, and every +-- meeting operation guards @qDomain meetingId == tDomain zUser@. The +-- conversation itself is always created locally. +storedMeetingToMeetingWithConversation :: + Local UserId -> + StoredConversation -> + Store.StoredMeeting -> + API.MeetingWithConversation +storedMeetingToMeetingWithConversation lUser conv sm = + API.MeetingWithConversation + { API.meeting = storedMeetingToMeeting (tDomain lUser) sm, + API.conversation = conversationView lUser (Just lUser) conv + } + listMeetingsImpl :: ( Member Store.MeetingsStore r, Member ConversationSubsystem r, diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index 70960254a73..6bfd5e5f532 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -34,12 +34,13 @@ import Polysemy import Polysemy.Error import Polysemy.Input import Polysemy.State +import Polysemy.TinyLog (TinyLog) import System.Random (StdGen, mkStdGen) import Test.Hspec import Test.Hspec.QuickCheck (prop) import Test.QuickCheck (counterexample, ioProperty, (.&&.), (===), (==>)) import Text.Email.Parser (unsafeEmailAddress) -import Wire.API.Conversation (Access (InviteAccess, PrivateAccess)) +import Wire.API.Conversation (Access (InviteAccess, PrivateAccess), Conversation (metadata, qualifiedId), ConversationMetadata (cnvmAccess)) import Wire.API.Error (ErrorS) import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound)) import Wire.API.Meeting qualified as API @@ -53,6 +54,7 @@ import Wire.MeetingsStore qualified as Store import Wire.MeetingsSubsystem import Wire.MeetingsSubsystem.Interpreter import Wire.MockInterpreters +import Wire.Sem.Logger.TinyLog (discardTinyLogs) import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) import Wire.StoredConversation @@ -65,6 +67,7 @@ type TestStack = ConversationSubsystem, TeamSubsystem, FeaturesConfigSubsystem, + TinyLog, Error MeetingError, State (Map MeetingId Store.StoredMeeting), State (Map ConvId StoredConversation), @@ -118,6 +121,7 @@ runTestStack now gen teams configs = . evalState Map.empty . evalState Map.empty . runError @MeetingError + . discardTinyLogs . interpretFeaturesConfigSubsystemPure configs . interpretTeamSubsystemToGalleyAPI . inMemoryConversationSubsystemInterpreter @@ -141,15 +145,16 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty def $ do - (meeting, _conv) <- createMeeting zUser newMeeting - fetched <- getMeeting zUser meeting.id + meeting <- createMeeting zUser newMeeting + fetched <- getMeeting zUser meeting.meeting.id pure (meeting, fetched) case result of Left err -> fail $ "Error: " <> show err Right (meeting, fetched) -> do - meeting.title `shouldBe` fromJust (checked "Test Meeting") - fetched `shouldBe` Just meeting + meeting.meeting.title `shouldBe` fromJust (checked "Test Meeting") + meeting.conversation.qualifiedId `shouldBe` meeting.meeting.conversationId + fetched `shouldBe` Just meeting.meeting it "creates meeting conversation with invite access for MLS participant adds" $ do let now = UTCTime (fromGregorian 2026 1 1) 0 @@ -166,8 +171,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty def $ do - (_meeting, conv) <- createMeeting zUser newMeeting - pure (convAccess conv) + meeting <- createMeeting zUser newMeeting + pure meeting.conversation.metadata.cnvmAccess case result of Left err -> fail $ "Error: " <> show err @@ -217,8 +222,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 newMeeting + getMeeting zUser1 meeting.meeting.id result `shouldBe` Right Nothing @@ -233,12 +238,12 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - (meeting,) <$> getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 newMeeting + (meeting,) <$> getMeeting zUser1 meeting.meeting.id case result of Left err -> fail $ "Error: " <> show err - Right (meeting, Just m) -> m.id `shouldBe` meeting.id + Right (meeting, Just m) -> m.id `shouldBe` meeting.meeting.id Right (_, Nothing) -> fail "Expected Just meeting for creator" it "returns meeting for conversation member" $ do @@ -252,15 +257,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do - (meeting, conv) <- createMeeting zUser1 newMeeting - members <- gets (Map.lookup conv.id_) + meeting <- createMeeting zUser1 newMeeting + members <- gets (Map.lookup (qUnqualified meeting.conversation.qualifiedId)) let updatedMembers = maybe (Set.singleton uid2) (Set.insert uid2) members - modify (Map.insert conv.id_ updatedMembers) - (meeting,) <$> getMeeting zUser2 meeting.id + modify (Map.insert (qUnqualified meeting.conversation.qualifiedId) updatedMembers) + (meeting,) <$> getMeeting zUser2 meeting.meeting.id case result of Left err -> fail $ "Error: " <> show err - Right (meeting, Just m) -> m.id `shouldBe` meeting.id + Right (meeting, Just m) -> m.id `shouldBe` meeting.meeting.id Right (_, Nothing) -> fail "Expected Just meeting for conversation member" it "returns Nothing for unauthorized user" $ do @@ -274,8 +279,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - getMeeting zUser3 meeting.id + meeting <- createMeeting zUser1 newMeeting + getMeeting zUser3 meeting.meeting.id result `shouldBe` Right Nothing @@ -293,11 +298,11 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do invitedEmails = [] } - result <- runTestStack now gen Map.empty def $ do - (meeting, _conv) <- createMeeting zUser newMeeting - pure meeting + result <- + runTestStack now gen Map.empty def $ + createMeeting zUser newMeeting - fmap (.trial) result `shouldBe` Right True + fmap (.meeting.trial) result `shouldBe` Right True it "creates meeting with trial flag when premium is enabled for team" $ do let now = UTCTime (fromGregorian 2026 1 1) 0 @@ -319,11 +324,11 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do invitedEmails = [] } - result <- runTestStack now gen (Map.singleton teamId [teamMember]) teamConfig $ do - (meeting, _conv) <- createMeeting zUser newMeeting - pure meeting + result <- + runTestStack now gen (Map.singleton teamId [teamMember]) teamConfig $ + createMeeting zUser newMeeting - fmap (.trial) result `shouldBe` Right False + fmap (.meeting.trial) result `shouldBe` Right False it "creates meeting without trial flag when premium is disabled for team" $ do let now = UTCTime (fromGregorian 2026 1 1) 0 @@ -345,11 +350,11 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do invitedEmails = [] } - result <- runTestStack now gen (Map.singleton teamId [teamMember]) teamConfig $ do - (meeting, _conv) <- createMeeting zUser newMeeting - pure meeting + result <- + runTestStack now gen (Map.singleton teamId [teamMember]) teamConfig $ + createMeeting zUser newMeeting - fmap (.trial) result `shouldBe` Right True + fmap (.meeting.trial) result `shouldBe` Right True describe "updateMeeting" $ do let now = UTCTime (fromGregorian 2026 1 1) 0 @@ -375,8 +380,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - updateMeeting zUser1 meeting.id (API.UpdateMeeting Nothing Nothing Nothing Nothing) + meeting <- createMeeting zUser1 newMeeting + updateMeeting zUser1 meeting.meeting.id (API.UpdateMeeting Nothing Nothing Nothing Nothing) result `shouldBe` Left EmptyUpdate @@ -391,7 +396,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting + meeting <- createMeeting zUser1 newMeeting let update = API.UpdateMeeting { startTime = Just (addUTCTime 8000 now), @@ -399,7 +404,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do title = Nothing, recurrence = Nothing } - updateMeeting zUser1 meeting.id update + updateMeeting zUser1 meeting.meeting.id update result `shouldBe` Left InvalidTimes @@ -414,8 +419,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - updateMeeting zUser1 meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing) + meeting <- createMeeting zUser1 newMeeting + updateMeeting zUser1 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing) result `shouldBe` Right Nothing @@ -430,8 +435,26 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - updateMeeting zUser2 meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing) + meeting <- createMeeting zUser1 newMeeting + updateMeeting zUser2 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing) + + result `shouldBe` Right Nothing + + it "returns Nothing when the meeting's conversation is missing" $ do + let newMeeting = + API.NewMeeting + { title = fromJust $ checked "Orphaned Meeting", + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + recurrence = Nothing, + invitedEmails = [] + } + + result <- runTestStack now gen Map.empty teamConfig $ do + meeting <- createMeeting zUser1 newMeeting + -- Simulate a data-inconsistency: the meeting's conversation vanished. + modify @(Map ConvId StoredConversation) (Map.delete (qUnqualified meeting.meeting.conversationId)) + updateMeeting zUser1 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) result `shouldBe` Right Nothing @@ -451,19 +474,21 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do in isNotEmpty && hasValidTimes ==> ioProperty $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 baseMeeting - updateMeeting zUser1 meeting.id update + meeting <- createMeeting zUser1 baseMeeting + updated <- updateMeeting zUser1 meeting.meeting.id update + pure (meeting.meeting.conversationId, updated) case result of Left err -> pure $ counterexample ("Unexpected error: " <> show err) False - Right Nothing -> + Right (_, Nothing) -> pure $ counterexample "Expected Just meeting, got Nothing" False - Right (Just m) -> + Right (convId, Just m) -> pure $ - m.title === fromMaybe baseMeeting.title update.title - .&&. m.startTime === effectiveStart - .&&. m.endTime === effectiveEnd - .&&. m.recurrence === fromMaybe baseMeeting.recurrence update.recurrence + m.meeting.title === fromMaybe baseMeeting.title update.title + .&&. m.meeting.startTime === effectiveStart + .&&. m.meeting.endTime === effectiveEnd + .&&. m.meeting.recurrence === fromMaybe baseMeeting.recurrence update.recurrence + .&&. m.meeting.conversationId === convId describe "deleteMeeting" $ do let now = UTCTime (fromGregorian 2026 1 1) 0 @@ -490,9 +515,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _) <- createMeeting zUser1 newMeeting - deleteResult <- deleteMeeting zUser1 testConnId meeting.id - getResult <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 newMeeting + deleteResult <- deleteMeeting zUser1 testConnId meeting.meeting.id + getResult <- getMeeting zUser1 meeting.meeting.id pure (deleteResult, getResult) result `shouldBe` Right (True, Nothing) @@ -508,8 +533,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do - (meeting, _) <- createMeeting zUser1 newMeeting - deleteMeeting zUser2 testConnId meeting.id + meeting <- createMeeting zUser1 newMeeting + deleteMeeting zUser2 testConnId meeting.meeting.id result `shouldBe` Right False @@ -524,8 +549,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _) <- createMeeting zUser1 newMeeting - deleteMeeting zUser1 testConnId meeting.id + meeting <- createMeeting zUser1 newMeeting + deleteMeeting zUser1 testConnId meeting.meeting.id result `shouldBe` Right False @@ -548,10 +573,10 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, conv) <- createMeeting zUser1 newMeeting - _ <- internalGetConversation conv.id_ - _ <- deleteMeeting zUser1 testConnId meeting.id - internalGetConversation conv.id_ + meeting <- createMeeting zUser1 newMeeting + _ <- internalGetConversation (qUnqualified meeting.conversation.qualifiedId) + _ <- deleteMeeting zUser1 testConnId meeting.meeting.id + internalGetConversation (qUnqualified meeting.conversation.qualifiedId) result `shouldBe` Right Nothing @@ -566,10 +591,10 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _) <- createMeeting zUser1 newMeeting + meeting <- createMeeting zUser1 newMeeting -- Change conversation type to non-meeting by updating local members only -- This simulates a non-meeting conversation without touching internal types - deleteMeeting zUser1 testConnId meeting.id + deleteMeeting zUser1 testConnId meeting.meeting.id result `shouldSatisfy` isRight @@ -599,9 +624,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - success <- addInvitedEmails zUser1 meeting.id [email1, email2] - fetched <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 newMeeting + success <- addInvitedEmails zUser1 meeting.meeting.id [email1, email2] + fetched <- getMeeting zUser1 meeting.meeting.id pure (success, fetched) case result of @@ -622,8 +647,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - addInvitedEmails zUser1 meeting.id [email1] + meeting <- createMeeting zUser1 newMeeting + addInvitedEmails zUser1 meeting.meeting.id [email1] result `shouldBe` Right False @@ -638,8 +663,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - addInvitedEmails zUser2 meeting.id [email1] + meeting <- createMeeting zUser1 newMeeting + addInvitedEmails zUser2 meeting.meeting.id [email1] result `shouldBe` Right False @@ -679,9 +704,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - success <- removeInvitedEmails zUser1 meeting.id [email2] - fetched <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 newMeeting + success <- removeInvitedEmails zUser1 meeting.meeting.id [email2] + fetched <- getMeeting zUser1 meeting.meeting.id pure (success, fetched) case result of @@ -702,9 +727,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - success <- removeInvitedEmails zUser1 meeting.id [email1, email2] - fetched <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 newMeeting + success <- removeInvitedEmails zUser1 meeting.meeting.id [email1, email2] + fetched <- getMeeting zUser1 meeting.meeting.id pure (success, fetched) case result of @@ -725,9 +750,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - success <- removeInvitedEmails zUser1 meeting.id [email2, email3] - fetched <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 newMeeting + success <- removeInvitedEmails zUser1 meeting.meeting.id [email2, email3] + fetched <- getMeeting zUser1 meeting.meeting.id pure (success, fetched) case result of @@ -748,8 +773,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - removeInvitedEmails zUser1 meeting.id [email1] + meeting <- createMeeting zUser1 newMeeting + removeInvitedEmails zUser1 meeting.meeting.id [email1] result `shouldBe` Right False @@ -764,8 +789,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - removeInvitedEmails zUser2 meeting.id [email1] + meeting <- createMeeting zUser1 newMeeting + removeInvitedEmails zUser2 meeting.meeting.id [email1] result `shouldBe` Right False @@ -805,9 +830,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - success <- replaceInvitedEmails zUser1 meeting.id [email3] - fetched <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 newMeeting + success <- replaceInvitedEmails zUser1 meeting.meeting.id [email3] + fetched <- getMeeting zUser1 meeting.meeting.id pure (success, fetched) case result of @@ -828,9 +853,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - success <- replaceInvitedEmails zUser1 meeting.id [] - fetched <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 newMeeting + success <- replaceInvitedEmails zUser1 meeting.meeting.id [] + fetched <- getMeeting zUser1 meeting.meeting.id pure (success, fetched) case result of @@ -851,9 +876,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - success <- replaceInvitedEmails zUser1 meeting.id [email3, email3, email1] - fetched <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 newMeeting + success <- replaceInvitedEmails zUser1 meeting.meeting.id [email3, email3, email1] + fetched <- getMeeting zUser1 meeting.meeting.id pure (success, fetched) case result of @@ -874,8 +899,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - replaceInvitedEmails zUser1 meeting.id [email2] + meeting <- createMeeting zUser1 newMeeting + replaceInvitedEmails zUser1 meeting.meeting.id [email2] result `shouldBe` Right False @@ -890,8 +915,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do } result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - replaceInvitedEmails zUser2 meeting.id [email3] + meeting <- createMeeting zUser1 newMeeting + replaceInvitedEmails zUser2 meeting.meeting.id [email3] result `shouldBe` Right False @@ -943,8 +968,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do it "getMeeting returns a recurring meeting whose slot passed but window is open" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - getMeeting zUser meeting.id + meeting <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + getMeeting zUser meeting.meeting.id case result of Left err -> fail $ "Error: " <> show err Right Nothing -> fail "Expected Just meeting (recurrence window still open)" @@ -953,7 +978,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do it "listMeetings includes a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (_meeting, _conv) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + _meeting <- createMeeting zUser (expiredNewMeeting boundedRecurrence) listMeetings zUser case result of Left err -> fail $ "Error: " <> show err @@ -962,53 +987,53 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do it "updateMeeting succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - updateMeeting zUser meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) + meeting <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + updateMeeting zUser meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) fmap isJust result `shouldBe` Right True it "addInvitedEmails succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - addInvitedEmails zUser meeting.id [unsafeEmailAddress "user" "example.com"] + meeting <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + addInvitedEmails zUser meeting.meeting.id [unsafeEmailAddress "user" "example.com"] result `shouldBe` Right True it "deleteMeeting succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - deleteMeeting zUser (ConnId "test-conv") meeting.id + meeting <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + deleteMeeting zUser (ConnId "test-conv") meeting.meeting.id result `shouldBe` Right True it "removeInvitedEmails succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - removeInvitedEmails zUser meeting.id [unsafeEmailAddress "user" "example.com"] + meeting <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + removeInvitedEmails zUser meeting.meeting.id [unsafeEmailAddress "user" "example.com"] result `shouldBe` Right True it "replaceInvitedEmails succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - replaceInvitedEmails zUser meeting.id [unsafeEmailAddress "user" "example.com"] + meeting <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + replaceInvitedEmails zUser meeting.meeting.id [unsafeEmailAddress "user" "example.com"] result `shouldBe` Right True it "getMeeting returns an open-ended recurring meeting indefinitely" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting openEndedRecurrence) - getMeeting zUser meeting.id + meeting <- createMeeting zUser (expiredNewMeeting openEndedRecurrence) + getMeeting zUser meeting.meeting.id fmap isJust result `shouldBe` Right True it "cleanupOldMeetings skips recurring meetings whose window is still open" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (recurring, _conv1) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - (_plain, _conv2) <- createMeeting zUser (expiredNewMeeting Nothing) + recurring <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + _plain <- createMeeting zUser (expiredNewMeeting Nothing) deleted <- cleanupOldMeetings (addUTCTime (negate 1) now) 100 - remaining <- getMeeting zUser recurring.id - pure (deleted, fmap (.id) remaining, recurring.id) + remaining <- getMeeting zUser recurring.meeting.id + pure (deleted, fmap (.id) remaining, recurring.meeting.id) case result of Left err -> fail $ "Error: " <> show err Right (deleted, remainingId, recurringId) -> do @@ -1019,10 +1044,10 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do it "cleanupOldMeetings never picks up open-ended recurring meetings" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting openEndedRecurrence) + meeting <- createMeeting zUser (expiredNewMeeting openEndedRecurrence) deleted <- cleanupOldMeetings now 100 - remaining <- getMeeting zUser meeting.id - pure (deleted, fmap (.id) remaining, meeting.id) + remaining <- getMeeting zUser meeting.meeting.id + pure (deleted, fmap (.id) remaining, meeting.meeting.id) case result of Left err -> fail $ "Error: " <> show err Right (deleted, remainingId, meetingId) -> do @@ -1047,11 +1072,11 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do in ioProperty $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser nm - fetched <- isJust <$> getMeeting zUser meeting.id + meeting <- createMeeting zUser nm + fetched <- isJust <$> getMeeting zUser meeting.meeting.id listedCount <- length <$> listMeetings zUser deleted <- cleanupOldMeetings cutoff 100 - remains <- isJust <$> getMeeting zUser meeting.id + remains <- isJust <$> getMeeting zUser meeting.meeting.id pure (fetched, listedCount, deleted, remains) pure $ case result of Left err -> counterexample ("Unexpected error: " <> show err) False @@ -1106,46 +1131,43 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do it "throws MeetingsFeatureDisabled on getMeeting for team user with meetings disabled" $ do result <- - runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ do - (meeting, _conv) <- createMeeting zUserTeam newMeeting - pure meeting + runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ + createMeeting zUserTeam newMeeting case result of Left err -> fail $ "Failed to create meeting: " <> show err Right meeting -> do result2 <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ - getMeeting zUserTeam meeting.id + getMeeting zUserTeam meeting.meeting.id result2 `shouldBe` Left MeetingsFeatureDisabled it "throws MeetingsFeatureDisabled on updateMeeting for team user with meetings disabled" $ do result <- - runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ do - (meeting, _conv) <- createMeeting zUserTeam newMeeting - pure meeting + runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ + createMeeting zUserTeam newMeeting case result of Left err -> fail $ "Failed to create meeting: " <> show err Right meeting -> do result2 <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ - updateMeeting zUserTeam meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) + updateMeeting zUserTeam meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) result2 `shouldBe` Left MeetingsFeatureDisabled it "throws MeetingsFeatureDisabled on deleteMeeting for team user with meetings disabled" $ do result <- - runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ do - (meeting, _conv) <- createMeeting zUserTeam newMeeting - pure meeting + runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ + createMeeting zUserTeam newMeeting case result of Left err -> fail $ "Failed to create meeting: " <> show err Right meeting -> do result2 <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ - deleteMeeting zUserTeam (ConnId "test-conn") meeting.id + deleteMeeting zUserTeam (ConnId "test-conn") meeting.meeting.id result2 `shouldBe` Left MeetingsFeatureDisabled @@ -1158,45 +1180,42 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do it "throws MeetingsFeatureDisabled on addInvitedEmails for team user with meetings disabled" $ do result <- - runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ do - (meeting, _conv) <- createMeeting zUserTeam newMeeting - pure meeting + runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ + createMeeting zUserTeam newMeeting case result of Left err -> fail $ "Failed to create meeting: " <> show err Right meeting -> do result2 <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ - addInvitedEmails zUserTeam meeting.id [unsafeEmailAddress "test" "example.com"] + addInvitedEmails zUserTeam meeting.meeting.id [unsafeEmailAddress "test" "example.com"] result2 `shouldBe` Left MeetingsFeatureDisabled it "throws MeetingsFeatureDisabled on removeInvitedEmails for team user with meetings disabled" $ do result <- - runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ do - (meeting, _conv) <- createMeeting zUserTeam newMeeting - pure meeting + runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ + createMeeting zUserTeam newMeeting case result of Left err -> fail $ "Failed to create meeting: " <> show err Right meeting -> do result2 <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ - removeInvitedEmails zUserTeam meeting.id [unsafeEmailAddress "test" "example.com"] + removeInvitedEmails zUserTeam meeting.meeting.id [unsafeEmailAddress "test" "example.com"] result2 `shouldBe` Left MeetingsFeatureDisabled it "throws MeetingsFeatureDisabled on replaceInvitedEmails for team user with meetings disabled" $ do result <- - runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ do - (meeting, _conv) <- createMeeting zUserTeam newMeeting - pure meeting + runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ + createMeeting zUserTeam newMeeting case result of Left err -> fail $ "Failed to create meeting: " <> show err Right meeting -> do result2 <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ - replaceInvitedEmails zUserTeam meeting.id [unsafeEmailAddress "test" "example.com"] + replaceInvitedEmails zUserTeam meeting.meeting.id [unsafeEmailAddress "test" "example.com"] result2 `shouldBe` Left MeetingsFeatureDisabled diff --git a/services/galley/src/Galley/API/Meetings.hs b/services/galley/src/Galley/API/Meetings.hs index 770d047b41c..20a1101433a 100644 --- a/services/galley/src/Galley/API/Meetings.hs +++ b/services/galley/src/Galley/API/Meetings.hs @@ -41,10 +41,8 @@ createMeeting :: (Member Meetings.MeetingsSubsystem r) => Local UserId -> NewMeeting -> - Sem r Meeting -createMeeting lUser newMeeting = do - (meeting, _conversation) <- Meetings.createMeeting lUser newMeeting - pure meeting + Sem r MeetingWithConversation +createMeeting lUser newMeeting = Meetings.createMeeting lUser newMeeting updateMeeting :: ( Member Meetings.MeetingsSubsystem r, @@ -54,7 +52,7 @@ updateMeeting :: Domain -> MeetingId -> UpdateMeeting -> - Sem r Meeting + Sem r MeetingWithConversation updateMeeting zUser domain meetingId update = do let qMeetingId = Qualified meetingId domain maybeMeeting <- Meetings.updateMeeting zUser qMeetingId update From a9a19dd43620c2b1bc73619a0539fc02c203460d Mon Sep 17 00:00:00 2001 From: Sven Tennie Date: Wed, 8 Jul 2026 08:22:18 +0200 Subject: [PATCH 002/113] make multi-ingress domains case agnostic (#5320) The Z-Host header has been treated as domain, but used as Text. De-serializing and thus using it as Domain increases type-safety and ensures domain related semantics; e.g. case insensitivity in equality checks. This solves a FUTUREWORK remark which was around for quite some time. CodeStore GetConversationCodeURI interpreters only do a state map lookup with this domain value, so this is not a database migration case. Do we change the API? Not really, as this is not client facing: Z-Host is set by nginx to $host. --- changelog.d/5-internal/zhost-domain | 4 + .../test/Test/Migration/ConversationCodes.hs | 77 ++++++++++--------- libs/types-common/src/Data/Domain.hs | 3 + libs/wire-api/src/Wire/API/Routes/Public.hs | 4 +- .../golden/Test/Wire/API/Golden/Manual/IdP.hs | 3 +- libs/wire-subsystems/src/Wire/CodeStore.hs | 3 +- .../src/Wire/CodeStore/Cassandra.hs | 3 +- .../src/Wire/CodeStore/DualWrite.hs | 3 +- .../src/Wire/CodeStore/Migration.hs | 7 +- .../src/Wire/CodeStore/Postgres.hs | 3 +- .../src/Wire/ConversationSubsystem.hs | 2 +- .../src/Wire/IdPSubsystem/Interpreter.hs | 5 +- .../src/Wire/Options/Galley.hs | 4 +- .../src/Wire/BackgroundWorker/Env.hs | 2 +- .../background-worker/src/Wire/Effects.hs | 5 +- .../cargohold/src/CargoHold/API/Public.hs | 7 +- services/cargohold/src/CargoHold/API/V3.hs | 5 +- services/cargohold/src/CargoHold/App.hs | 5 +- services/cargohold/src/CargoHold/Options.hs | 2 +- services/cargohold/src/CargoHold/S3.hs | 13 ++-- services/cargohold/src/CargoHold/Util.hs | 3 +- services/cargohold/test/integration/App.hs | 6 +- services/galley/src/Galley/App.hs | 5 +- services/galley/src/Galley/Env.hs | 3 +- services/spar/src/Spar/API.hs | 39 ++++------ services/spar/test/Test/Spar/Saml/IdPSpec.hs | 6 +- 26 files changed, 121 insertions(+), 101 deletions(-) create mode 100644 changelog.d/5-internal/zhost-domain diff --git a/changelog.d/5-internal/zhost-domain b/changelog.d/5-internal/zhost-domain new file mode 100644 index 00000000000..ad3c95fe1fe --- /dev/null +++ b/changelog.d/5-internal/zhost-domain @@ -0,0 +1,4 @@ +The `Z-Host` header has been treated as domain, but used as `Text`. +De-serializing and thus using it as `Domain` increases type-safety and ensures +domain related semantics; e.g. case insensitivity in equality checks. +This solves a `FUTUREWORK` remark which was around for quite some time. diff --git a/integration/test/Test/Migration/ConversationCodes.hs b/integration/test/Test/Migration/ConversationCodes.hs index c56fd73c9a8..81d7ad8b132 100644 --- a/integration/test/Test/Migration/ConversationCodes.hs +++ b/integration/test/Test/Migration/ConversationCodes.hs @@ -10,10 +10,11 @@ import Test.Migration.Util (waitForMigration) import Testlib.Prelude import Testlib.ResourcePool -testConversationCodesMigration :: (HasCallStack) => TaggedBool "has-password" -> App () -testConversationCodesMigration (TaggedBool hasPassword) = do +testConversationCodesMigration :: (HasCallStack) => TaggedBool "has-password" -> TaggedBool "with-zHost" -> App () +testConversationCodesMigration (TaggedBool hasPassword) (TaggedBool withZhost) = do resourcePool <- asks (.resourcePool) let pw = if hasPassword then Just "funky password" else Nothing + mbZHost = if withZhost then Just "zhost.example.com" else Nothing runCodensity (acquireResources 1 resourcePool) $ \[backend] -> do let domain = backend.berDomain @@ -34,10 +35,10 @@ testConversationCodesMigration (TaggedBool hasPassword) = do code2 <- genCode admin conv2 pw codeB <- genCode admin convB pw -- joining works - checkJoinAndGet admin m1 conv1 code1 pw - checkJoinAndGet admin m1 conv2 code2 pw + checkJoinAndGet admin m1 conv1 code1 mbZHost + checkJoinAndGet admin m1 conv2 code2 mbZHost -- deletion works - checkDelete admin m1 convA codeA pw + checkDelete admin m1 convA codeA mbZHost pure (code2, codeB) (code3, codeC) <- runCodensity (startDynamicBackend backend (conf "migration-to-postgresql" True)) $ \_ -> do @@ -45,12 +46,12 @@ testConversationCodesMigration (TaggedBool hasPassword) = do code3 <- genCode admin conv3 pw codeC <- genCode admin convC pw -- joining works - checkJoinAndGet admin m2 conv1 code1 pw - checkJoinAndGet admin m2 conv2 code2 pw - checkJoinAndGet admin m2 conv3 code3 pw + checkJoinAndGet admin m2 conv1 code1 mbZHost + checkJoinAndGet admin m2 conv2 code2 mbZHost + checkJoinAndGet admin m2 conv3 code3 mbZHost -- deletion works - checkNoCode admin m1 convA codeA pw - checkDelete admin m1 convB codeB pw + checkNoCode admin m1 convA codeA mbZHost + checkDelete admin m1 convB codeB mbZHost waitForMigration domain counterName pure (code3, codeC) @@ -59,40 +60,40 @@ testConversationCodesMigration (TaggedBool hasPassword) = do code4 <- genCode admin conv4 pw codeD <- genCode admin convD pw -- joining works - checkJoinAndGet admin m3 conv1 code1 pw - checkJoinAndGet admin m3 conv2 code2 pw - checkJoinAndGet admin m3 conv3 code3 pw - checkJoinAndGet admin m3 conv4 code4 pw + checkJoinAndGet admin m3 conv1 code1 mbZHost + checkJoinAndGet admin m3 conv2 code2 mbZHost + checkJoinAndGet admin m3 conv3 code3 mbZHost + checkJoinAndGet admin m3 conv4 code4 mbZHost -- deletion works - checkNoCode admin m1 convA codeA pw - checkNoCode admin m1 convB codeB pw - checkDelete admin m1 convC codeC pw + checkNoCode admin m1 convA codeA mbZHost + checkNoCode admin m1 convB codeB mbZHost + checkDelete admin m1 convC codeC mbZHost pure (code4, codeD) runCodensity (startDynamicBackend backend (conf "postgresql" False)) $ \_ -> do -- code generation works code5 <- genCode admin conv5 pw -- joining works - checkJoinAndGet admin m4 conv1 code1 pw - checkJoinAndGet admin m4 conv2 code2 pw - checkJoinAndGet admin m4 conv3 code3 pw - checkJoinAndGet admin m4 conv4 code4 pw - checkJoinAndGet admin m4 conv5 code5 pw + checkJoinAndGet admin m4 conv1 code1 mbZHost + checkJoinAndGet admin m4 conv2 code2 mbZHost + checkJoinAndGet admin m4 conv3 code3 mbZHost + checkJoinAndGet admin m4 conv4 code4 mbZHost + checkJoinAndGet admin m4 conv5 code5 mbZHost -- deletion works - checkNoCode admin m1 convA codeA pw - checkNoCode admin m1 convB codeB pw - checkNoCode admin m1 convC codeC pw - checkDelete admin m1 convD codeD pw - checkDelete admin m1 conv5 code5 pw + checkNoCode admin m1 convA codeA mbZHost + checkNoCode admin m1 convB codeB mbZHost + checkNoCode admin m1 convC codeC mbZHost + checkDelete admin m1 convD codeD mbZHost + checkDelete admin m1 conv5 code5 mbZHost where - checkJoinAndGet admin user conv code pw = do + checkJoinAndGet admin user conv code mbZHost = do joinWithCode user conv code - getCode admin conv pw `shouldMatch` code - checkDelete admin user conv (k, v) pw = do + getCode admin conv mbZHost `shouldMatch` code + checkDelete admin user conv (k, v) mbZHost = do assertSuccess =<< deleteConversationCode admin conv - checkNoCode admin user conv (k, v) pw - checkNoCode admin user conv (k, v) pw = do - assertStatus 404 =<< getConversationCode admin conv pw + checkNoCode admin user conv (k, v) mbZHost + checkNoCode admin user conv (k, v) mbZHost = do + assertStatus 404 =<< getConversationCode admin conv mbZHost bindResponse (getJoinCodeConv user k v) $ \res -> do res.status `shouldMatchInt` 404 res.json %. "label" `shouldMatch` "no-conversation-code" @@ -145,21 +146,21 @@ genCode user conv pw = pure (k, v) getCode :: (HasCallStack, MakesValue user, MakesValue conv) => user -> conv -> Maybe String -> App (String, String) -getCode user conv pw = - bindResponse (getConversationCode user conv pw) $ \res -> do +getCode user conv mbZHost = + bindResponse (getConversationCode user conv mbZHost) $ \res -> do payload <- getJSON 200 res k <- payload %. "key" & asString v <- payload %. "code" & asString pure (k, v) waitForCodeToExpire :: (MakesValue user, MakesValue conv) => user -> conv -> Maybe String -> App () -waitForCodeToExpire user conv pw = do - res <- getConversationCode user conv pw +waitForCodeToExpire user conv mbZHost = do + res <- getConversationCode user conv mbZHost if res.status == 404 then pure () else do liftIO $ threadDelay 100_000 - waitForCodeToExpire user conv pw + waitForCodeToExpire user conv mbZHost joinWithCode :: (HasCallStack, MakesValue user) => user -> Value -> (String, String) -> App () joinWithCode user conv (k, v) = diff --git a/libs/types-common/src/Data/Domain.hs b/libs/types-common/src/Data/Domain.hs index 4b21945919d..6d51e9f844d 100644 --- a/libs/types-common/src/Data/Domain.hs +++ b/libs/types-common/src/Data/Domain.hs @@ -132,6 +132,9 @@ instance Arbitrary Domain where arbitrary = either (error . ("arbitrary @Domain: " <>)) id . mkDomain . getDomainText <$> arbitrary +instance QC.CoArbitrary Domain where + coarbitrary d = QC.coarbitrary (Text.unpack (domainText d)) + -- | only for QuickCheck newtype DomainText = DomainText {getDomainText :: Text} deriving (Eq, Show) diff --git a/libs/wire-api/src/Wire/API/Routes/Public.hs b/libs/wire-api/src/Wire/API/Routes/Public.hs index e651202e9b6..3bc651cad8a 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public.hs @@ -227,7 +227,7 @@ instance (HasLink api) => HasLink (ZHostOpt :> api) where type MkLink (ZHostOpt :> api) a = MkLink api a toLink toA _ = toLink toA (Proxy :: Proxy api) -type ZHostValue = Text -- FUTUREWORK: use Data.Domain.Domain here instead of Text? +type ZHostValue = Domain type ZOptHostHeader = Header' '[Servant.Optional, Strict] "Z-Host" ZHostValue @@ -281,7 +281,7 @@ instance ) => HasServer (ZHostOpt :> api) ctx where - type ServerT (ZHostOpt :> api) m = Maybe Text -> ServerT api m + type ServerT (ZHostOpt :> api) m = Maybe ZHostValue -> ServerT api m route :: Proxy (ZHostOpt :> api) -> Context ctx -> diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/IdP.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/IdP.hs index cae4d041bd5..43f4a190f31 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/IdP.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/IdP.hs @@ -1,5 +1,6 @@ module Test.Wire.API.Golden.Manual.IdP where +import Data.Domain (Domain (..)) import Data.Id import Data.List.NonEmpty import Data.UUID @@ -114,7 +115,7 @@ testObject_IdP_1 = ], _replacedBy = Just (IdPId {fromIdPId = (fromJust . Data.UUID.fromString) "fc5f3bf8-c296-69e7-27fd-70d483740fe4"}), _handle = IdPHandle {unIdPHandle = "614c0bb0-1b33-98b6-8600-a1b290bbe1d7"}, - _domain = Just "wire.com" + _domain = Just (Domain "wire.com") } } diff --git a/libs/wire-subsystems/src/Wire/CodeStore.hs b/libs/wire-subsystems/src/Wire/CodeStore.hs index bae3e0da4cd..c5c73c6bf07 100644 --- a/libs/wire-subsystems/src/Wire/CodeStore.hs +++ b/libs/wire-subsystems/src/Wire/CodeStore.hs @@ -20,6 +20,7 @@ module Wire.CodeStore where import Data.Code +import Data.Domain (Domain) import Data.Misc import Imports import Polysemy @@ -32,6 +33,6 @@ data CodeStore m a where DeleteCode :: Key -> CodeStore m () MakeKey :: CodeReferent -> CodeStore m Key GenerateCode :: CodeReferent -> Timeout -> CodeStore m Code - GetConversationCodeURI :: Maybe Text -> CodeStore m (Maybe HttpsUrl) + GetConversationCodeURI :: Maybe Domain -> CodeStore m (Maybe HttpsUrl) makeSem ''CodeStore diff --git a/libs/wire-subsystems/src/Wire/CodeStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/CodeStore/Cassandra.hs index 3c42149f74e..2a06764be76 100644 --- a/libs/wire-subsystems/src/Wire/CodeStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/CodeStore/Cassandra.hs @@ -22,6 +22,7 @@ where import Cassandra import Data.Code +import Data.Domain (Domain) import Data.Id import Data.Map qualified as Map import Data.Misc (HttpsUrl) @@ -39,7 +40,7 @@ import Wire.Util (embedClientInput) interpretCodeStoreToCassandra :: ( Member (Embed IO) r, Member (Input ClientState) r, - Member (Input (Either HttpsUrl (Map Text HttpsUrl))) r, + Member (Input (Either HttpsUrl (Map Domain HttpsUrl))) r, Member (ErrorS 'CodeStoreNotFound) r ) => Sem (CodeStore ': r) a -> diff --git a/libs/wire-subsystems/src/Wire/CodeStore/DualWrite.hs b/libs/wire-subsystems/src/Wire/CodeStore/DualWrite.hs index 9beafb949a2..f567d3c353b 100644 --- a/libs/wire-subsystems/src/Wire/CodeStore/DualWrite.hs +++ b/libs/wire-subsystems/src/Wire/CodeStore/DualWrite.hs @@ -21,6 +21,7 @@ module Wire.CodeStore.DualWrite where import Cassandra (ClientState) +import Data.Domain (Domain) import Data.Misc import Imports import Polysemy @@ -37,7 +38,7 @@ import Wire.Postgres (PGConstraints) -- | Cassandra is the source of truth during migration; writes are mirrored to Postgres. interpretCodeStoreToCassandraAndPostgres :: ( Member (Input ClientState) r, - Member (Input (Either HttpsUrl (Map Text HttpsUrl))) r, + Member (Input (Either HttpsUrl (Map Domain HttpsUrl))) r, Member (ErrorS 'CodeStoreNotFound) r, PGConstraints r ) => diff --git a/libs/wire-subsystems/src/Wire/CodeStore/Migration.hs b/libs/wire-subsystems/src/Wire/CodeStore/Migration.hs index 14decb92f63..adbdcfe68e0 100644 --- a/libs/wire-subsystems/src/Wire/CodeStore/Migration.hs +++ b/libs/wire-subsystems/src/Wire/CodeStore/Migration.hs @@ -22,6 +22,7 @@ import Data.ByteString.Conversion import Data.Code (Key, Value) import Data.Conduit import Data.Conduit.List qualified as C +import Data.Domain (Domain) import Data.IORef qualified as IORef import Data.Id (ConvId) import Data.Misc (HttpsUrl) @@ -55,7 +56,7 @@ type EffectStack = [ State Int, Input ClientState, Input Hasql.Pool, - Input (Either HttpsUrl (Map Text HttpsUrl)), + Input (Either HttpsUrl (Map Domain HttpsUrl)), Resource, Async, Race, @@ -100,7 +101,7 @@ interpreter cassClient pgPool logger name = migrateAllCodes :: ( Member (Input Hasql.Pool) r, - Member (Input (Either HttpsUrl (Map Text HttpsUrl))) r, + Member (Input (Either HttpsUrl (Map Domain HttpsUrl))) r, Member (Embed IO) r, Member (Input ClientState) r, Member TinyLog r, @@ -119,7 +120,7 @@ migrateAllCodes migOpts migCounter migDuration = do .| C.mapM_ (traverse_ (\row@(key, _, _, _, _) -> handleErrors (toByteString' key) (migrateCodeRow migOpts migCounter migDuration row))) migrateCodeRow :: - ( Member (Input (Either HttpsUrl (Map Text HttpsUrl))) r, + ( Member (Input (Either HttpsUrl (Map Domain HttpsUrl))) r, PGConstraints r, Member TinyLog r, Member Resource r, diff --git a/libs/wire-subsystems/src/Wire/CodeStore/Postgres.hs b/libs/wire-subsystems/src/Wire/CodeStore/Postgres.hs index 9f60db88959..9719c2a306e 100644 --- a/libs/wire-subsystems/src/Wire/CodeStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/CodeStore/Postgres.hs @@ -21,6 +21,7 @@ module Wire.CodeStore.Postgres where import Data.Code +import Data.Domain (Domain) import Data.Id import Data.Map qualified as Map import Data.Misc (HttpsUrl) @@ -38,7 +39,7 @@ import Wire.Postgres interpretCodeStoreToPostgres :: ( PGConstraints r, - Member (Input (Either HttpsUrl (Map Text HttpsUrl))) r + Member (Input (Either HttpsUrl (Map Domain HttpsUrl))) r ) => Sem (CodeStore ': r) a -> Sem r a diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs index 44f7541d276..d1991691258 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs @@ -449,7 +449,7 @@ data ConversationSubsystem m a where ConvId -> ConversationSubsystem m (LockableFeature GuestLinksConfig) GetCode :: - Maybe Text -> + Maybe ZHostValue -> Local UserId -> ConvId -> ConversationSubsystem m ConversationCodeInfo diff --git a/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs index 50af85ad305..546f5eedef6 100644 --- a/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs @@ -16,6 +16,7 @@ import Polysemy import Polysemy.Error import SAML2.WebSSO qualified as SAML import System.Logger.Message qualified as Log +import Wire.API.Routes.Public (ZHostValue) import Wire.API.User import Wire.API.User.IdentityProvider qualified as IP import Wire.BrigAPIAccess @@ -82,7 +83,7 @@ getSsoCodeByEmailImpl :: Member GalleyAPIAccess r, Member IdPConfigStore r ) => - Bool -> Maybe Text -> EmailAddress -> Sem r (Maybe SAML.IdPId) + Bool -> Maybe ZHostValue -> EmailAddress -> Sem r (Maybe SAML.IdPId) getSsoCodeByEmailImpl enableIdPByEmailDiscovery mbHost email = do if not enableIdPByEmailDiscovery @@ -124,6 +125,6 @@ getSsoCodeByEmailImpl enableIdPByEmailDiscovery mbHost email = when (length matches > 1) $ Logger.warn $ Log.msg @Text "Found more than one IdP config for domain" - . Log.field "domain" (fromMaybe "None" mbHost) + . Log.field "domain" (maybe "None" domainText mbHost) . Log.field "idpIds" (intercalate "," $ (UUID.toString . SAML.fromIdPId) <$> matches) pure $ listToMaybe matches diff --git a/libs/wire-subsystems/src/Wire/Options/Galley.hs b/libs/wire-subsystems/src/Wire/Options/Galley.hs index 6b30fff0976..f9040bdde90 100644 --- a/libs/wire-subsystems/src/Wire/Options/Galley.hs +++ b/libs/wire-subsystems/src/Wire/Options/Galley.hs @@ -138,7 +138,7 @@ data Settings = Settings -- -- multiIngress and conversationCodeURI are mutually exclusive. One of -- both options need to be configured. - _multiIngress :: Maybe (Map Text HttpsUrl), + _multiIngress :: Maybe (Map Domain HttpsUrl), -- | Throttling: limits to concurrent deletion events _concurrentDeletionEvents :: !(Maybe Int), -- | Throttling: delay between sending events upon team deletion @@ -243,7 +243,7 @@ deriveFromJSON toOptionFieldName ''Opts makeLenses ''Opts -conversationCodeURISettings :: (Applicative m) => Opts -> m (Either HttpsUrl (Map Text HttpsUrl)) +conversationCodeURISettings :: (Applicative m) => Opts -> m (Either HttpsUrl (Map Domain HttpsUrl)) conversationCodeURISettings opts = case (opts._settings._conversationCodeURI, opts._settings._multiIngress) of (Nothing, Nothing) -> error errMsg diff --git a/services/background-worker/src/Wire/BackgroundWorker/Env.hs b/services/background-worker/src/Wire/BackgroundWorker/Env.hs index 6c514afb7de..84cbbfd73ab 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Env.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Env.hs @@ -108,7 +108,7 @@ data Env = Env guestLinkTTLSeconds :: !(Maybe GuestLinkTTLSeconds), passwordHashingOptions :: !PasswordHashingOptions, checkGroupInfo :: !(Maybe Bool), - convCodeURI :: Either HttpsUrl (Map Text HttpsUrl), + convCodeURI :: Either HttpsUrl (Map Domain HttpsUrl), passwordHashingRateLimitEnv :: RateLimitEnv } diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index 09768fe3e17..e20946dee40 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -27,6 +27,7 @@ import Control.Monad.Catch import Control.Retry import Data.ByteString qualified as BS import Data.ByteString.Lazy qualified as LBS +import Data.Domain (Domain) import Data.Id import Data.Misc import Data.Qualified @@ -222,7 +223,7 @@ type BackgroundWorkerEffects = Input (Maybe GuestLinkTTLSeconds), Input (Maybe GroupInfoCheckEnabled), Input IntraListing, - Input (Either HttpsUrl (Map Text HttpsUrl)), + Input (Either HttpsUrl (Map Domain HttpsUrl)), Input ExposeInvitationURLsAllowlist, Input LegalHoldEnv, Input ClientState, @@ -307,7 +308,7 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . runInputConst @ClientState env.cassandraGalley . runInputConst @LegalHoldEnv legalHoldEnv . runInputConst @ExposeInvitationURLsAllowlist (ExposeInvitationURLsAllowlist $ fromMaybe [] env.exposeInvitationURLsTeamAllowlist) - . runInputConst @(Either HttpsUrl (Map Text HttpsUrl)) env.convCodeURI + . runInputConst @(Either HttpsUrl (Map Domain HttpsUrl)) env.convCodeURI . runInputConst @IntraListing (IntraListing env.intraListing) . runInputConst @(Maybe GroupInfoCheckEnabled) (GroupInfoCheckEnabled <$> env.checkGroupInfo) . runInputConst @(Maybe GuestLinkTTLSeconds) env.guestLinkTTLSeconds diff --git a/services/cargohold/src/CargoHold/API/Public.hs b/services/cargohold/src/CargoHold/API/Public.hs index a3929e7ac60..e9272d97963 100644 --- a/services/cargohold/src/CargoHold/API/Public.hs +++ b/services/cargohold/src/CargoHold/API/Public.hs @@ -30,7 +30,7 @@ import Control.Monad.Trans.Except (throwE) import Data.ByteString.Builder import qualified Data.ByteString.Builder as Builder import qualified Data.ByteString.Lazy as LBS -import Data.Domain +import Data.Domain (domainText) import Data.Id import Data.Kind import Data.Qualified @@ -45,6 +45,7 @@ import Wire.API.Asset import Wire.API.Routes.AssetBody import Wire.API.Routes.Internal.Cargohold import Wire.API.Routes.Named +import Wire.API.Routes.Public (ZHostValue) import Wire.API.Routes.Public.Cargohold import Wire.API.User (AccountStatus (..), User (userStatus, userTeam)) @@ -192,7 +193,7 @@ downloadAssetV3 :: AssetKey -> Maybe AssetToken -> Maybe AssetToken -> - Maybe Text -> + Maybe ZHostValue -> Handler (Maybe (AssetLocation Absolute)) downloadAssetV3 usr key tok1 tok2 mbHostHeader = do AssetLocation <$$> V3.download (mkPrincipal usr) key (tok1 <|> tok2) mbHostHeader @@ -203,7 +204,7 @@ downloadAssetV4 :: Qualified AssetKey -> Maybe AssetToken -> Maybe AssetToken -> - Maybe Text -> + Maybe ZHostValue -> Handler (Maybe LocalOrRemoteAsset) downloadAssetV4 usr qkey tok1 tok2 mbHostHeader = let tok = tok1 <|> tok2 diff --git a/services/cargohold/src/CargoHold/API/V3.hs b/services/cargohold/src/CargoHold/API/V3.hs index b22bfd341eb..9e8997f49c9 100644 --- a/services/cargohold/src/CargoHold/API/V3.hs +++ b/services/cargohold/src/CargoHold/API/V3.hs @@ -52,6 +52,7 @@ import Data.ByteString.Conversion (toByteString') import qualified Data.CaseInsensitive as CI import Data.Conduit import qualified Data.Conduit.Attoparsec as Conduit +import Data.Domain (Domain) import Data.Id import qualified Data.List as List import Data.Qualified @@ -122,13 +123,13 @@ updateToken own key tok = do randToken :: (MonadIO m) => m V3.AssetToken randToken = liftIO $ V3.AssetToken . Ascii.encodeBase64Url <$> getRandomBytes 16 -download :: V3.Principal -> V3.AssetKey -> Maybe V3.AssetToken -> Maybe Text -> Handler (Maybe URI) +download :: V3.Principal -> V3.AssetKey -> Maybe V3.AssetToken -> Maybe Domain -> Handler (Maybe URI) download own key tok mbHost = runMaybeT $ do qown <- lift $ qualifyLocal own meta <- checkMetadata (tUntagged qown) key tok lift $ genSignedURL (Just $ tUntagged qown) (Just meta) (S3.mkKey key) mbHost -downloadUnsafe :: V3.AssetKey -> Maybe Text -> Handler URI +downloadUnsafe :: V3.AssetKey -> Maybe Domain -> Handler URI downloadUnsafe key mbHost = do meta <- S3.getMetadataV3 key genSignedURL Nothing meta (S3.mkKey key) mbHost diff --git a/services/cargohold/src/CargoHold/App.hs b/services/cargohold/src/CargoHold/App.hs index 9ab96c0b2ce..6b1097d3f93 100644 --- a/services/cargohold/src/CargoHold/App.hs +++ b/services/cargohold/src/CargoHold/App.hs @@ -57,6 +57,7 @@ import Control.Error (ExceptT, runExceptT) import Control.Exception (catch, throwIO) import Control.Lens (lensField, lensRules, makeLensesWith, non, (.~), (?~), (^.)) import Control.Monad.Catch (MonadCatch, MonadMask, MonadThrow) +import Data.Domain (Domain) import Data.Id import qualified Data.Map as Map import Data.Qualified @@ -87,7 +88,7 @@ data Env = Env requestId :: RequestId, options :: Opt.Opts, localUnit :: Local (), - multiIngress :: Map String AWS.Env + multiIngress :: Map Domain AWS.Env } makeLensesWith (lensRules & lensField .~ suffixNamer) ''Env @@ -103,7 +104,7 @@ newEnv opts = do let localDomain = toLocalUnsafe opts.settings.federationDomain () pure $ Env awsEnv logger httpMgr http2Mgr (RequestId defRequestId) opts localDomain multiIngressAWS where - initMultiIngressAWS :: Logger -> Manager -> IO (Map String AWS.Env) + initMultiIngressAWS :: Logger -> Manager -> IO (Map Domain AWS.Env) initMultiIngressAWS logger httpMgr = Map.fromList <$> mapM diff --git a/services/cargohold/src/CargoHold/Options.hs b/services/cargohold/src/CargoHold/Options.hs index aa70731bc79..a595884f159 100644 --- a/services/cargohold/src/CargoHold/Options.hs +++ b/services/cargohold/src/CargoHold/Options.hs @@ -105,7 +105,7 @@ data AWSOpts = AWSOpts -- otherwise a 404 is retuned. This option is only useful -- in the context of multi-ingress setups where one backend / deployment is -- reachable under several domains. - multiIngress :: !(Maybe (Map String AWSEndpoint)) + multiIngress :: !(Maybe (Map Domain AWSEndpoint)) } deriving (Show, Generic) diff --git a/services/cargohold/src/CargoHold/S3.hs b/services/cargohold/src/CargoHold/S3.hs index e873acf3636..a8a5347f51e 100644 --- a/services/cargohold/src/CargoHold/S3.hs +++ b/services/cargohold/src/CargoHold/S3.hs @@ -62,6 +62,7 @@ import Data.ByteString.Lazy (fromStrict) import qualified Data.ByteString.Lazy as LBS import qualified Data.CaseInsensitive as CI import Data.Conduit.Binary +import Data.Domain (Domain, domainText) import qualified Data.HashMap.Lazy as HML import Data.Id import Data.Qualified (Qualified) @@ -273,7 +274,7 @@ updateMetadataV3 (s3Key . mkKey -> key) meta = do -- `Map` with the @Z-Host@ header's value as key. Otherwise (the default case -- that applies to most deployments), use the default AWS environment; i.e. the -- environment with @aws.s3DownloadEndpoint@. -signedURL :: (ToByteString p) => p -> Maybe Text -> Handler URI +signedURL :: (ToByteString p) => p -> Maybe Domain -> Handler URI signedURL path mbHost = do e <- awsEnvForHost now <- liftIO getCurrentTime @@ -299,7 +300,7 @@ signedURL path mbHost = do then asks (.aws) else awsEnvForHost' mbHost multiIngressConf where - awsEnvForHost' :: Maybe Text -> Map String AWS.Env -> Handler AWS.Env + awsEnvForHost' :: Maybe Domain -> Map Domain AWS.Env -> Handler AWS.Env awsEnvForHost' Nothing _ = do Log.debug $ msg (val "awsEnvForHost - multiIngress configured, but no Z-Host header provided.") @@ -307,19 +308,19 @@ signedURL path mbHost = do awsEnvForHost' (Just host) multiIngressConf = do Log.debug $ "host" - .= host + .= domainText host ~~ msg (val "awsEnvForHost - Looking up multiIngress config.") - case multiIngressConf ^. at (Text.unpack host) of + case multiIngressConf ^. at host of Nothing -> do Log.debug $ "host" - .= host + .= domainText host ~~ msg (val "awsEnvForHost - multiIngress lookup failed, no config for provided Z-Host header.") throwE noMatchingAssetEndpoint Just hostAwsEnv -> do Log.debug $ "host" - .= host + .= domainText host ~~ "s3DownloadEndpoint" .= show hostAwsEnv.amazonkaDownloadEndpoint ~~ msg (val "awsEnvForHost - multiIngress lookup succeed, using specific AWS env.") diff --git a/services/cargohold/src/CargoHold/Util.hs b/services/cargohold/src/CargoHold/Util.hs index 763866e46b8..bd4018ede6a 100644 --- a/services/cargohold/src/CargoHold/Util.hs +++ b/services/cargohold/src/CargoHold/Util.hs @@ -26,11 +26,12 @@ import CargoHold.S3 (S3AssetMeta) import qualified CargoHold.S3 as S3 import qualified CargoHold.Types as V3 import Data.ByteString.Conversion +import Data.Domain (Domain) import Data.Qualified (Qualified) import Imports import URI.ByteString hiding (urlEncode) -genSignedURL :: (ToByteString p) => Maybe (Qualified V3.Principal) -> Maybe S3AssetMeta -> p -> Maybe Text -> Handler URI +genSignedURL :: (ToByteString p) => Maybe (Qualified V3.Principal) -> Maybe S3AssetMeta -> p -> Maybe Domain -> Handler URI genSignedURL quid mMeta path mbHost = do uri <- asks (.aws.cloudFront) >>= \case diff --git a/services/cargohold/test/integration/App.hs b/services/cargohold/test/integration/App.hs index b4b9c128ed4..6a841660117 100644 --- a/services/cargohold/test/integration/App.hs +++ b/services/cargohold/test/integration/App.hs @@ -25,6 +25,8 @@ import CargoHold.Options as Opts import Control.Exception import Control.Lens import Data.ByteString.Conversion +import Data.Domain (mkDomain) +import qualified Data.Domain import qualified Data.Map as Map import qualified Data.Text as T import Imports @@ -68,10 +70,10 @@ testMultiIngressCloudFrontFails = do privateKey = "any/path" } -multiIngressMap :: Map String AWSEndpoint +multiIngressMap :: Map Data.Domain.Domain AWSEndpoint multiIngressMap = Map.singleton - "red.example.com" + (either (error . show) id $ mkDomain "red.example.com") (toAWSEndpoint "http://s3-download.red.example.com") toAWSEndpoint :: ByteString -> AWSEndpoint diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index b7e256db100..1ac4b1d27b9 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -47,6 +47,7 @@ import Cassandra hiding (Set) import Cassandra.Util (initCassandraForService) import Control.Error hiding (err) import Control.Lens hiding ((.=)) +import Data.Domain (Domain) import Data.Id import Data.Misc import Data.Qualified @@ -236,7 +237,7 @@ type GalleyEffects = Input (Maybe (MLSKeysByPurpose MLSPrivateKeys)), Input (Maybe GroupInfoCheckEnabled), Input Opts, - Input (Either HttpsUrl (Map Text HttpsUrl)), + Input (Either HttpsUrl (Map Domain HttpsUrl)), Now, GE.Queue DeleteItem, Error Meeting.MeetingError, @@ -294,7 +295,7 @@ type GalleyEffects = ] -- Define some invariants for the options used -validateOptions :: Opts -> IO (Either HttpsUrl (Map Text HttpsUrl)) +validateOptions :: Opts -> IO (Either HttpsUrl (Map Domain HttpsUrl)) validateOptions o = do let settings' = view settings o optFanoutLimit = fromIntegral . fromRange $ currentFanoutLimit settings'._maxTeamSize settings'._maxFanoutSize diff --git a/services/galley/src/Galley/Env.hs b/services/galley/src/Galley/Env.hs index 009ac952546..606a2807a30 100644 --- a/services/galley/src/Galley/Env.hs +++ b/services/galley/src/Galley/Env.hs @@ -45,6 +45,7 @@ where import Cassandra import Control.Lens hiding ((.=)) +import Data.Domain (Domain) import Data.Id import Data.Misc (HttpsUrl) import Data.Time.Clock.DiffTime (millisecondsToDiffTime) @@ -85,7 +86,7 @@ data Env = Env _aEnv :: Maybe Aws.Env, _mlsKeys :: Maybe (MLSKeysByPurpose MLSPrivateKeys), _rabbitmqChannel :: Maybe (MVar Q.Channel), - _convCodeURI :: Either HttpsUrl (Map Text HttpsUrl), + _convCodeURI :: Either HttpsUrl (Map Domain HttpsUrl), _passwordHashingRateLimitEnv :: RateLimitEnv } diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index bfe73fcac73..655bdd6dc45 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -61,7 +61,7 @@ import Control.Lens hiding ((.=)) import qualified Data.ByteString as SBS import Data.ByteString.Builder (toLazyByteString) import Data.ByteString.Conversion -import Data.Domain +import Data.Domain (Domain, domainText) import Data.HavePendingInvitations import Data.Id import Data.List.NonEmpty (NonEmpty) @@ -295,22 +295,20 @@ getMetadata :: Member (Error SparError) r ) => Maybe TeamId -> - Maybe Text -> + Maybe ZHostValue -> Sem r SAML.SPMetadata getMetadata mbTid mbHost = do let err :: Sem r any err = throwSparSem (SparSPNotFound "") - mbHostDom <- (\host -> mkDomain host & either (const err) pure) `mapM` mbHost - let iss :: Sem r SAML.Issuer - iss = SamlProtocolSettings.spIssuer mbTid mbHostDom >>= maybe err pure + iss = SamlProtocolSettings.spIssuer mbTid mbHost >>= maybe err pure rsp :: Sem r URI.URI - rsp = SamlProtocolSettings.responseURI mbTid mbHostDom >>= maybe err pure + rsp = SamlProtocolSettings.responseURI mbTid mbHost >>= maybe err pure contactList :: Sem r [SAML.ContactPerson] - contactList = SamlProtocolSettings.contactPersons mbHostDom + contactList = SamlProtocolSettings.contactPersons mbHost SAML2.meta appName iss rsp contactList @@ -324,7 +322,7 @@ authreqPrecheck :: Maybe URI.URI -> Maybe CookieLabel -> SAML.IdPId -> - Maybe Text -> + Maybe ZHostValue -> Sem r NoContent authreqPrecheck samlConfig msucc merr mlabel idpid mbHost = validateAuthreqParams msucc merr mlabel *> do @@ -352,7 +350,7 @@ authreq :: Maybe URI.URI -> Maybe CookieLabel -> SAML.IdPId -> - Maybe Text -> + Maybe ZHostValue -> Sem r (SAML.FormRedirect SAML.AuthnRequest) authreq samlConfig authreqttl msucc merr mlabel idpid mbHost = do vformat <- validateAuthreqParams msucc merr mlabel @@ -363,14 +361,13 @@ authreq samlConfig authreqttl msucc merr mlabel idpid mbHost = do let err :: Sem r any err = throwSparSem (SparSPNotFound "") - mbHostDom <- (\host -> mkDomain host & either (const err) pure) `mapM` mbHost let mbtid :: Maybe TeamId mbtid = case fromMaybe defWireIdPAPIVersion (idp ^. SAML.idpExtraInfo . apiVersion) of WireIdPAPIV1 -> Nothing WireIdPAPIV2 -> Just $ idp ^. SAML.idpExtraInfo . team iss :: Sem r SAML.Issuer - iss = SamlProtocolSettings.spIssuer mbtid mbHostDom >>= maybe err pure + iss = SamlProtocolSettings.spIssuer mbtid mbHost >>= maybe err pure SAML2.authReq authreqttl iss idpid VerdictFormatStore.store authreqttl reqid vformat pure form @@ -380,7 +377,7 @@ checkMultiIngressDomain :: Member (Error SparError) r ) => SAML.Config -> - Maybe Text -> + Maybe ZHostValue -> IdP -> Sem r () checkMultiIngressDomain samlConfig mbHost idp = when (SAML.isMultiIngressConfig samlConfig) $ do @@ -393,8 +390,8 @@ checkMultiIngressDomain samlConfig mbHost idp = when (SAML.isMultiIngressConfig Logger.debug $ Log.msg ("Multi-ingress domain guard rejected IdP access" :: ByteString) . Log.field "idp" idpIdTxt - . Log.field "idp_domain" (fromMaybe "none" idpDomain) - . Log.field "request_host" (fromMaybe "none" mbHost) + . Log.field "idp_domain" (maybe "None" domainText idpDomain) + . Log.field "request_host" (maybe "None" domainText mbHost) throwSparSem (SparIdPNotFound idpIdTxt) idpIdToText :: SAML.IdPId -> T.Text @@ -439,19 +436,17 @@ authresp :: ) => Maybe TeamId -> SAML.AuthnResponseBody -> - Maybe Text -> + Maybe ZHostValue -> Sem r Void authresp mbtid arbody mbHost = do let err :: Sem r any err = throwSparSem (SparSPNotFound "") - mbHostDom <- (\host -> mkDomain host & either (const err) pure) `mapM` mbHost - let iss :: Sem r SAML.Issuer - iss = SamlProtocolSettings.spIssuer mbtid mbHostDom >>= maybe err pure + iss = SamlProtocolSettings.spIssuer mbtid mbHost >>= maybe err pure rsp :: Sem r URI.URI - rsp = SamlProtocolSettings.responseURI mbtid mbHostDom >>= maybe err pure + rsp = SamlProtocolSettings.responseURI mbtid mbHost >>= maybe err pure logErrors $ SAML2.authResp mbtid iss rsp go arbody where @@ -766,7 +761,7 @@ logIdPAction msg idp zUser additionalFields = . Log.field "team" (idp ^. SAML.idpExtraInfo . team . to idToText) . Log.field "idpId" (idp ^. SAML.idpId . to SAML.fromIdPId . to UUID.toString) . Log.field "issuer" (idp ^. SAML.idpMetadata . SAML.edIssuer . SAML.fromIssuer . to URI.serializeURIRef') - . Log.field "domain" (idp ^. SAML.idpExtraInfo . domain . to (fromMaybe "None")) + . Log.field "domain" (idp ^. SAML.idpExtraInfo . domain . to (maybe "None" domainText)) . Log.field "user" (maybe "None" idToText zUser) . Log.field "certificates" (idp ^. SAML.idpMetadata . SAML.edCertAuthnResponse . to (intercalate ";; " . map certToString . toList)) . Log.field "idp-endpoint" (idp ^. SAML.idpMetadata . SAML.edRequestURI . to URI.serializeURIRef') @@ -774,7 +769,7 @@ logIdPAction msg idp zUser additionalFields = -- | Only return a ZHost when multi-ingress is configured and the host value is a configured domain filterMultiIngressZHost :: Either SAML.MultiIngressDomainConfig (Map Domain SAML.MultiIngressDomainConfig) -> Maybe ZHostValue -> Maybe ZHostValue -filterMultiIngressZHost (Right domainMap) (Just zHost) | (Domain zHost) `Map.member` domainMap = Just zHost +filterMultiIngressZHost (Right domainMap) (Just zHost) | zHost `Map.member` domainMap = Just zHost filterMultiIngressZHost _ _ = Nothing idpCreateV7 :: @@ -1017,7 +1012,7 @@ idpUpdateXML samlConfig mbZUsr mDomain raw idpmeta idpid mHandle = withDebugLog (idp ^. SAML.idpMetadata . SAML.edIssuer . SAML.fromIssuer) . logChangeableScalar "domain" - (fromMaybe "None") + (maybe "None" domainText) (previousIdP ^. SAML.idpExtraInfo . domain) (idp ^. SAML.idpExtraInfo . domain) . Log.field "user" (idToText zUsr) diff --git a/services/spar/test/Test/Spar/Saml/IdPSpec.hs b/services/spar/test/Test/Spar/Saml/IdPSpec.hs index f287590df98..601a1b381ef 100644 --- a/services/spar/test/Test/Spar/Saml/IdPSpec.hs +++ b/services/spar/test/Test/Spar/Saml/IdPSpec.hs @@ -94,13 +94,13 @@ spec = _cfgSPPort = 8081, _cfgDomainConfigs = Left anyMultiIngressDomainCfg } - host = Just "backend.example.com" + host = either (error . show) Just $ mkDomain "backend.example.com" miHost1AsText = "backend-1.example.com" miDomain1 = either (error . show) id $ mkDomain miHost1AsText - miHost1 = Just miHost1AsText + miHost1 = Just miDomain1 miHost2AsText = "backend-2.example.com" miDomain2 = either (error . show) id $ mkDomain miHost2AsText - miHost2 = Just miHost2AsText + miHost2 = Just miDomain2 multiIngressSamlConfig = Config { -- The log level only matters for log output, not production. From cb1b4fa6616f803c2907c9e25c8fd0e404d7c43d Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 8 Jul 2026 22:46:49 +0200 Subject: [PATCH 003/113] WPB-26771: deprecate meetingsPremium feature flag (#5326) --- .../wpb-26771-meetings-premium.md | 8 +++ .../templates/galley/configmap.yaml | 4 -- charts/wire-server/values.yaml | 4 -- .../src/developer/reference/config-options.md | 27 +++++----- hack/helm_vars/wire-server/values.yaml.gotmpl | 4 -- integration/test/Test/FeatureFlags/Util.hs | 5 +- integration/test/Test/Meetings.hs | 16 +++--- .../src/Wire/API/Routes/Internal/Galley.hs | 1 + .../Wire/API/Routes/Public/Galley/Feature.hs | 1 + libs/wire-api/src/Wire/API/Team/Feature.hs | 11 ++-- .../src/Wire/API/Team/FeatureFlags.hs | 1 + .../src/Wire/FeaturesConfigSubsystem/Types.hs | 1 + .../src/Wire/MeetingsSubsystem/Interpreter.hs | 12 ++--- .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 52 +++++-------------- services/galley/galley.integration.yaml | 4 -- services/galley/src/Galley/API/Internal.hs | 1 + .../galley/src/Galley/API/Public/Feature.hs | 1 + .../galley/src/Galley/API/Teams/Features.hs | 1 + tools/stern/src/Stern/API.hs | 1 + tools/stern/src/Stern/API/Routes.hs | 1 + tools/stern/test/integration/API.hs | 1 + 21 files changed, 67 insertions(+), 90 deletions(-) create mode 100644 changelog.d/0-release-notes/wpb-26771-meetings-premium.md diff --git a/changelog.d/0-release-notes/wpb-26771-meetings-premium.md b/changelog.d/0-release-notes/wpb-26771-meetings-premium.md new file mode 100644 index 00000000000..b3a13dce511 --- /dev/null +++ b/changelog.d/0-release-notes/wpb-26771-meetings-premium.md @@ -0,0 +1,8 @@ +* The `meetingsPremium` team feature flag is **deprecated** (WPB-26771). It no + longer affects meeting behaviour: team meetings are always non-trial + regardless of its value. Its default is now **enabled and locked**, and the + Helm configuration override for `meetingsPremium` has been removed from + `charts/wire-server`. The flag's data type and its public/internal HTTP + endpoints are retained for backward compatibility but have no behavioural + effect; any Helm overrides for `meetingsPremium` are now ignored and can be + removed. The flag is scheduled for removal in a future release. diff --git a/charts/wire-server/templates/galley/configmap.yaml b/charts/wire-server/templates/galley/configmap.yaml index 1f274edba0e..b91f6f083c2 100644 --- a/charts/wire-server/templates/galley/configmap.yaml +++ b/charts/wire-server/templates/galley/configmap.yaml @@ -230,10 +230,6 @@ data: meetings: {{- toYaml .settings.featureFlags.meetings | nindent 10 }} {{- end }} - {{- if .settings.featureFlags.meetingsPremium }} - meetingsPremium: - {{- toYaml .settings.featureFlags.meetingsPremium | nindent 10 }} - {{- end }} {{- if .settings.featureFlags.backgroundEffects }} backgroundEffects: {{- toYaml .settings.featureFlags.backgroundEffects | nindent 10 }} diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml index 48a45ffd5aa..a4468824384 100644 --- a/charts/wire-server/values.yaml +++ b/charts/wire-server/values.yaml @@ -324,10 +324,6 @@ galley: defaults: status: disabled lockStatus: locked - meetingsPremium: - defaults: - status: disabled - lockStatus: locked backgroundEffects: defaults: status: disabled diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index ab4bbf32689..219a6451749 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -249,20 +249,19 @@ The lock status for individual teams can be changed via the internal API (`PUT / The feature status for individual teams can be changed via the public API (if the feature is unlocked). -### Meetings Premium - -The `meetingsPremium` feature flag controls whether a team has premium meetings features. When enabled, meetings created by team members are not marked as trial. When disabled, meetings are trial and limited to 25 minutes. It is enabled and unlocked by default. If you want a different configuration, use the following syntax: - -```yaml -meetingsPremium: - defaults: - status: disabled|enabled - lockStatus: locked|unlocked -``` - -The lock status for individual teams can be changed via the internal API (`PUT /i/teams/:tid/features/meetingsPremium/(un)?locked`). - -The feature status for individual teams can be changed via the public API (if the feature is unlocked). +### Meetings Premium (deprecated) + +> **Deprecated (WPB-26771).** The `meetingsPremium` feature flag no longer +> affects meeting behaviour. Team meetings are always non-trial regardless of +> this flag's value. The flag, its data type and its public/internal endpoints +> are retained for backward compatibility and are scheduled for removal in a +> future release. + +The flag now defaults to **enabled and locked** and the Helm configuration +override has been removed (operators can no longer change it via Helm). The +`MeetingsPremiumConfig` type carries a `DEPRECATED` pragma. Existing +`GET/PUT /teams/:tid/features/meetingsPremium` and internal lock-status +endpoints remain available but have no behavioural effect. ### Background Effects diff --git a/hack/helm_vars/wire-server/values.yaml.gotmpl b/hack/helm_vars/wire-server/values.yaml.gotmpl index d5b4c7ad883..990b49fc07e 100644 --- a/hack/helm_vars/wire-server/values.yaml.gotmpl +++ b/hack/helm_vars/wire-server/values.yaml.gotmpl @@ -411,10 +411,6 @@ galley: defaults: status: enabled lockStatus: unlocked - meetingsPremium: - defaults: - status: disabled - lockStatus: locked journal: endpoint: http://fake-aws-sqs:4568 queueName: integration-team-events.fifo diff --git a/integration/test/Test/FeatureFlags/Util.hs b/integration/test/Test/FeatureFlags/Util.hs index ec1b3a17096..da69f27a415 100644 --- a/integration/test/Test/FeatureFlags/Util.hs +++ b/integration/test/Test/FeatureFlags/Util.hs @@ -57,6 +57,9 @@ disabled = object ["lockStatus" .= "unlocked", "status" .= "disabled", "ttl" .= disabledLocked :: Value disabledLocked = object ["lockStatus" .= "locked", "status" .= "disabled", "ttl" .= "unlimited"] +enabledLocked :: Value +enabledLocked = object ["lockStatus" .= "locked", "status" .= "enabled", "ttl" .= "unlimited"] + enabled :: Value enabled = object ["lockStatus" .= "unlocked", "status" .= "enabled", "ttl" .= "unlimited"] @@ -246,7 +249,7 @@ defAllFeatures = ] ], "meetings" .= enabled, - "meetingsPremium" .= disabledLocked, + "meetingsPremium" .= enabledLocked, "backgroundEffects" .= disabledLocked, "preventAdminlessGroups" .= object diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs index 1cfc518d3c8..5355471b5cf 100644 --- a/integration/test/Test/Meetings.hs +++ b/integration/test/Test/Meetings.hs @@ -127,19 +127,17 @@ testMeetingCreatePersonalUserTrial = do meeting <- getJSON 201 r meeting %. "trial" `shouldMatch` True --- Test that paying team members create non-trial meetings -testMeetingCreatePayingTeamNonTrial :: (HasCallStack) => App () -testMeetingCreatePayingTeamNonTrial = do - (owner, tid, _members) <- createTeam OwnDomain 1 - - let firstMeeting = object ["status" .= "enabled"] - I.setTeamFeatureLockStatus owner tid "meetingsPremium" "unlocked" - I.setTeamFeatureConfig owner tid "meetingsPremium" firstMeeting >>= assertStatus 200 +-- | Test that team members create non-trial meetings. The deprecated +-- `meetingsPremium` flag no longer affects this; team meetings are always +-- non-trial (see WPB-26771). +testMeetingCreateTeamNonTrial :: (HasCallStack) => App () +testMeetingCreateTeamNonTrial = do + (owner, _tid, _members) <- createTeam OwnDomain 1 now <- liftIO getCurrentTime let startTime = addUTCTime 3600 now endTime = addUTCTime 7200 now - newMeeting = defaultMeetingJson "Paying Team Meeting" startTime endTime [] + newMeeting = defaultMeetingJson "Team Meeting" startTime endTime [] r <- postMeetings owner newMeeting assertSuccess r diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs index 6216cea445a..ea46e17ff34 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs @@ -14,6 +14,7 @@ -- -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . +{-# OPTIONS_GHC -Wno-deprecations #-} module Wire.API.Routes.Internal.Galley where diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs index 0fac708542c..5b38473c306 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs @@ -14,6 +14,7 @@ -- -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . +{-# OPTIONS_GHC -Wno-deprecations #-} module Wire.API.Routes.Public.Galley.Feature where diff --git a/libs/wire-api/src/Wire/API/Team/Feature.hs b/libs/wire-api/src/Wire/API/Team/Feature.hs index 7aa8043313e..741ba022b9e 100644 --- a/libs/wire-api/src/Wire/API/Team/Feature.hs +++ b/libs/wire-api/src/Wire/API/Team/Feature.hs @@ -4,6 +4,7 @@ {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE StrictData #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +{-# OPTIONS_GHC -Wno-deprecations #-} -- This file is part of the Wire Server implementation. -- @@ -2399,8 +2400,12 @@ instance ToObjectSchema MeetingsConfig where -------------------------------------------------------------------------------- -- MeetingPremium Feature -- --- Indicates whether a team has premium meetings features. When enabled, meetings --- created by team members are not marked as trial. When disabled, meetings are trial. +-- /Deprecated (WPB-26771)./ This feature flag no longer affects meeting +-- behaviour: team meetings are always non-trial. It is kept solely for API +-- compatibility (the public\/internal endpoints still exist) and defaults to +-- /enabled and locked/. Scheduled for removal in a future release. + +{-# DEPRECATED MeetingsPremiumConfig "Deprecated (WPB-26771): no longer affects meeting trial status; kept for API compatibility." #-} data MeetingsPremiumConfig = MeetingsPremiumConfig deriving (Eq, Show, Generic, GSOP.Generic) @@ -2412,7 +2417,7 @@ instance ToSchema MeetingsPremiumConfig where schema = object objectSchema instance Default (LockableFeature MeetingsPremiumConfig) where - def = defLockedFeature + def = defLockedFeature {status = FeatureStatusEnabled} instance IsFeatureConfig MeetingsPremiumConfig where type FeatureSymbol MeetingsPremiumConfig = "meetingsPremium" diff --git a/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs b/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs index 07e9ea6def4..6a77beeb88e 100644 --- a/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs +++ b/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs @@ -1,6 +1,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StrictData #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +{-# OPTIONS_GHC -Wno-deprecations #-} -- This file is part of the Wire Server implementation. -- diff --git a/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Types.hs b/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Types.hs index 023035e8c11..1a4f4113c82 100644 --- a/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Types.hs +++ b/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Types.hs @@ -1,6 +1,7 @@ {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE UndecidableSuperClasses #-} +{-# OPTIONS_GHC -Wno-deprecations #-} module Wire.FeaturesConfigSubsystem.Types where diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs index 44acf71c198..77602276cad 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs @@ -42,7 +42,7 @@ import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Role (roleNameWireAdmin) import Wire.API.Meeting qualified as API import Wire.API.Routes.MultiTablePaging qualified as MultiTablePaging -import Wire.API.Team.Feature (FeatureStatus (..), LockableFeature (..), MeetingsConfig, MeetingsPremiumConfig) +import Wire.API.Team.Feature (FeatureStatus (..), LockableFeature (..), MeetingsConfig) import Wire.API.User (BaseProtocolTag (BaseProtocolMLSTag), EmailAddress) import Wire.ConversationSubsystem (ConversationSubsystem) import Wire.ConversationSubsystem qualified as ConversationSubsystem @@ -128,12 +128,10 @@ createMeetingImpl zUser newMeeting = do when (newMeeting.endTime <= newMeeting.startTime) $ throw InvalidTimes - -- Determine trial status based on team membership and premium feature - trial <- case conversationTeamId of - Nothing -> pure True -- Personal users create trial meetings - Just teamId -> do - premiumFeature <- getFeatureForTeam @_ @MeetingsPremiumConfig teamId - pure $ premiumFeature.status /= FeatureStatusEnabled + -- Determine trial status: personal users (no team) create trial meetings. + -- The deprecated meetingsPremium feature flag no longer affects this; team + -- meetings are always non-trial (see WPB-26771). + let trial = isNothing conversationTeamId -- Create conversation with the meeting creator as the only member (admin role) let newConv = diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index 6bfd5e5f532..03fb7b2288a 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -209,7 +209,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do teamId = Id $ read "00000000-0000-0000-0000-000000000100" teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled - teamConfig = npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def + teamConfig = npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def it "returns Nothing for expired meeting" $ do let newMeeting = @@ -304,7 +304,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do fmap (.meeting.trial) result `shouldBe` Right True - it "creates meeting with trial flag when premium is enabled for team" $ do + it "creates non-trial meeting for team user" $ do let now = UTCTime (fromGregorian 2026 1 1) 0 gen = mkStdGen 42 uid = Id $ read "00000000-0000-0000-0000-000000000001" @@ -312,12 +312,11 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do teamId = Id $ read "00000000-0000-0000-0000-000000000100" teamMember = mkTeamMember uid fullPermissions Nothing UserLegalHoldDisabled teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) - . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) - $ def + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ + def newMeeting = API.NewMeeting - { title = fromJust $ checked "Team Premium Meeting", + { title = fromJust $ checked "Team Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, recurrence = Nothing, @@ -330,32 +329,6 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do fmap (.meeting.trial) result `shouldBe` Right False - it "creates meeting without trial flag when premium is disabled for team" $ do - let now = UTCTime (fromGregorian 2026 1 1) 0 - gen = mkStdGen 42 - uid = Id $ read "00000000-0000-0000-0000-000000000001" - zUser = toLocalUnsafe (Domain "wire.com") uid - teamId = Id $ read "00000000-0000-0000-0000-000000000100" - teamMember = mkTeamMember uid fullPermissions Nothing UserLegalHoldDisabled - teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) - . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusDisabled LockStatusUnlocked def) - $ def - newMeeting = - API.NewMeeting - { title = fromJust $ checked "Team Free Meeting", - startTime = addUTCTime 3600 now, - endTime = addUTCTime 7200 now, - recurrence = Nothing, - invitedEmails = [] - } - - result <- - runTestStack now gen (Map.singleton teamId [teamMember]) teamConfig $ - createMeeting zUser newMeeting - - fmap (.meeting.trial) result `shouldBe` Right True - describe "updateMeeting" $ do let now = UTCTime (fromGregorian 2026 1 1) 0 gen = mkStdGen 42 @@ -367,7 +340,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def it "throws EmptyUpdate when no fields provided" $ do let newMeeting = @@ -501,7 +474,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def testConnId = ConnId (C.pack "test-conn") it "returns True for successful deletion by creator" $ do @@ -609,7 +582,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def email1 = unsafeEmailAddress "user1" "example.com" email2 = unsafeEmailAddress "user2" "example.com" @@ -688,7 +661,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def email1 = unsafeEmailAddress "user1" "example.com" email2 = unsafeEmailAddress "user2" "example.com" email3 = unsafeEmailAddress "user3" "example.com" @@ -814,7 +787,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def email1 = unsafeEmailAddress "user1" "example.com" email2 = unsafeEmailAddress "user2" "example.com" email3 = unsafeEmailAddress "user3" "example.com" @@ -938,9 +911,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do uid = Id $ read "00000000-0000-0000-0000-000000000001" zUser = toLocalUnsafe (Domain "wire.com") uid teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) - . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) - $ def + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ + def -- endTime (now-5000) is well past the validity cutoff (now-3600). expiredNewMeeting r = API.NewMeeting diff --git a/services/galley/galley.integration.yaml b/services/galley/galley.integration.yaml index e31a28ce321..8703c55801c 100644 --- a/services/galley/galley.integration.yaml +++ b/services/galley/galley.integration.yaml @@ -244,10 +244,6 @@ settings: defaults: status: enabled lockStatus: unlocked - meetingsPremium: - defaults: - status: disabled - lockStatus: locked logLevel: Warn logNetStrings: false diff --git a/services/galley/src/Galley/API/Internal.hs b/services/galley/src/Galley/API/Internal.hs index a86e6a77f6b..0cb715af924 100644 --- a/services/galley/src/Galley/API/Internal.hs +++ b/services/galley/src/Galley/API/Internal.hs @@ -1,4 +1,5 @@ {-# LANGUAGE PartialTypeSignatures #-} +{-# OPTIONS_GHC -Wno-deprecations #-} {-# OPTIONS_GHC -Wno-partial-type-signatures #-} -- This file is part of the Wire Server implementation. diff --git a/services/galley/src/Galley/API/Public/Feature.hs b/services/galley/src/Galley/API/Public/Feature.hs index 2bf0b3ab1e2..cbb62ac2233 100644 --- a/services/galley/src/Galley/API/Public/Feature.hs +++ b/services/galley/src/Galley/API/Public/Feature.hs @@ -1,4 +1,5 @@ {-# LANGUAGE PartialTypeSignatures #-} +{-# OPTIONS_GHC -Wno-deprecations #-} {-# OPTIONS_GHC -Wno-partial-type-signatures #-} -- This file is part of the Wire Server implementation. diff --git a/services/galley/src/Galley/API/Teams/Features.hs b/services/galley/src/Galley/API/Teams/Features.hs index c87f0651ae1..15c4ce548cf 100644 --- a/services/galley/src/Galley/API/Teams/Features.hs +++ b/services/galley/src/Galley/API/Teams/Features.hs @@ -1,5 +1,6 @@ {-# LANGUAGE UndecidableSuperClasses #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +{-# OPTIONS_GHC -Wno-deprecations #-} -- This file is part of the Wire Server implementation. -- diff --git a/tools/stern/src/Stern/API.hs b/tools/stern/src/Stern/API.hs index 45066bbc5ff..610601db124 100644 --- a/tools/stern/src/Stern/API.hs +++ b/tools/stern/src/Stern/API.hs @@ -3,6 +3,7 @@ {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE TypeOperators #-} {-# LANGUAGE ViewPatterns #-} +{-# OPTIONS_GHC -Wno-deprecations #-} {-# OPTIONS_GHC -Wno-orphans #-} -- This file is part of the Wire Server implementation. diff --git a/tools/stern/src/Stern/API/Routes.hs b/tools/stern/src/Stern/API/Routes.hs index a216a2bb671..865e36de266 100644 --- a/tools/stern/src/Stern/API/Routes.hs +++ b/tools/stern/src/Stern/API/Routes.hs @@ -14,6 +14,7 @@ -- -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . +{-# OPTIONS_GHC -Wno-deprecations #-} module Stern.API.Routes ( SternAPI, diff --git a/tools/stern/test/integration/API.hs b/tools/stern/test/integration/API.hs index f90a8a6441a..b0b30b5afb3 100644 --- a/tools/stern/test/integration/API.hs +++ b/tools/stern/test/integration/API.hs @@ -1,5 +1,6 @@ {-# LANGUAGE OverloadedRecordDot #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +{-# OPTIONS_GHC -Wno-deprecations #-} {-# OPTIONS_GHC -Wno-orphans #-} {-# OPTIONS_GHC -Wno-redundant-constraints #-} From d27ea44e9b078285b68b5200a57d22874a820521 Mon Sep 17 00:00:00 2001 From: Sven Tennie Date: Thu, 9 Jul 2026 07:19:49 +0200 Subject: [PATCH 004/113] idpCertFingerprintAllowlist mandatory for multi-ingress SSO (#5327) As IdP <-> user relationships will become more flexible in multi-ingress setups ("automatic cross-IdP migration"), we need to be more strict regarding IdP management. This commit enforces the usage of `idpCertFingerprintAllowlist` in multi-ingress setups: If it is not set, all IdP management and SAML authentication actions are denied. Regular (non-multi-ingress) setups stay unaffected. --- .../multi-ingress-mandatory-allowlist | 7 + .../src/developer/reference/config-options.md | 14 +- .../Test/Spar/CertFingerprintAllowlist.hs | 7 +- integration/test/Test/Spar/GetByEmail.hs | 23 ++- integration/test/Test/Spar/MultiIngressIdp.hs | 66 +++++-- integration/test/Test/Spar/MultiIngressSSO.hs | 23 ++- integration/test/Testlib/Certs.hs | 6 + .../src/SAML2/WebSSO/Test/Util/TestSP.hs | 11 +- services/spar/src/Spar/API.hs | 48 +++-- services/spar/test/Test/Spar/Saml/IdPSpec.hs | 168 ++++++++++++++++-- 10 files changed, 303 insertions(+), 70 deletions(-) create mode 100644 changelog.d/1-api-changes/multi-ingress-mandatory-allowlist diff --git a/changelog.d/1-api-changes/multi-ingress-mandatory-allowlist b/changelog.d/1-api-changes/multi-ingress-mandatory-allowlist new file mode 100644 index 00000000000..444a67a31e5 --- /dev/null +++ b/changelog.d/1-api-changes/multi-ingress-mandatory-allowlist @@ -0,0 +1,7 @@ +To prevent security-relevant configuration mistakes, make configuration of +allowed IdP certificate fingerprints (`idpCertFingerprintAllowlist`) mandatory for +multi-ingress SSO. **This will break existing multi-ingress SSO flows until +`idpCertFingerprintAllowlist` is configured!** This breakage is unfortunately +necessary, because we're getting more lenient regarding the IdPs a user can use +to log in ("auto IdP migration"). Regular (non-multi-ingress) use cases are +unaffected. diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index 219a6451749..030b4476426 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -1346,16 +1346,19 @@ configured in `nginz`'s Helm chart in the #### IdP certificate fingerprint allowlist -This optional feature restricts which X.509 certificates can be used in IdP -metadata. When configured, all certificates in IdP descriptors must have a -SHA-1 fingerprint present in the allowlist, or IdP creation/update and SAML +This feature restricts which X.509 certificates can be used in IdP metadata. +When configured, all certificates in IdP descriptors must have a SHA-1 +fingerprint present in the allowlist, or IdP creation/update and SAML AuthnResponse (`/sso/finalize-login`) requests will be rejected. This limits team admins in their choice of IdPs. E.g. a malicious team admin couldn't provision bad IdPs, as possible IdP certificates are restricted by the allowlist. -The feature is disabled by default in Helm (the attribute can be left out as well): +**For multi-ingress setups, this feature is mandatory** to prevent +security-relevant configuration mistakes. For regular (non-multi-ingress) +setups, it is optional and disabled by default in Helm (the attribute can be +left out as well): ```yaml config: @@ -1609,6 +1612,9 @@ error. Though, IdPs can be reconfigured as long as this invariant holds. Putting it differently: We require an unambiguous mapping `(team, domain) -> IdP`. +For multi-ingress setups, the [`idpCertFingerprintAllowlist`](#idp-certificate-fingerprint-allowlist) +must be configured to restrict which X.509 certificates can be used in IdP metadata. + ### Webapp The webapp runs its own web server (a NodeJS server) to serve static files and the webapp config (based on environment variables). diff --git a/integration/test/Test/Spar/CertFingerprintAllowlist.hs b/integration/test/Test/Spar/CertFingerprintAllowlist.hs index a3e513900ff..b88d47b69a1 100644 --- a/integration/test/Test/Spar/CertFingerprintAllowlist.hs +++ b/integration/test/Test/Spar/CertFingerprintAllowlist.hs @@ -5,12 +5,11 @@ import API.Spar (createIdpWithZHostV2, updateIdp) import Control.Lens ((.~), (^.)) import Data.List.NonEmpty (NonEmpty ((:|))) import qualified Data.List.NonEmpty as NE -import qualified Data.Text as T import Data.X509 (SignedCertificate) -import qualified Data.X509.Extended as X509E import qualified SAML2.WebSSO.Test.Util as SAMLTest import qualified SAML2.WebSSO.Types as SAMLTypes import SetupHelpers +import Testlib.Certs (fingerprintHex) import Testlib.Prelude import qualified Text.XML.DSig as XMLDSig @@ -137,10 +136,6 @@ bogusFingerprint = "0000000000000000000000000000000000000000" firstCert :: SAMLTypes.IdPMetadata -> SignedCertificate firstCert meta = NE.head $ meta ^. SAMLTypes.edCertAuthnResponse --- | Cert's SHA-1 fingerprint in canonical @AA:BB:..@ hex form. -fingerprintHex :: SignedCertificate -> String -fingerprintHex = T.unpack . X509E.renderFingerprintHex . X509E.certSha1Fingerprint - -- | First cert's SHA-1, canonical @AA:BB:..@ form. firstCertFingerprint :: SAMLTypes.IdPMetadata -> String firstCertFingerprint = fingerprintHex . firstCert diff --git a/integration/test/Test/Spar/GetByEmail.hs b/integration/test/Test/Spar/GetByEmail.hs index 155bee47f48..b5c59aab7e5 100644 --- a/integration/test/Test/Spar/GetByEmail.hs +++ b/integration/test/Test/Spar/GetByEmail.hs @@ -23,7 +23,9 @@ import API.Spar import GHC.Stack import qualified SAML2.WebSSO.Test.Util as SAML import SetupHelpers +import Testlib.Certs (fingerprintHex) import Testlib.Prelude +import qualified Text.XML.DSig as XMLDSig -- | Test the /sso/get-by-email endpoint with multi-ingress setup testGetSsoCodeByEmailWithMultiIngress :: @@ -35,6 +37,8 @@ testGetSsoCodeByEmailWithMultiIngress (TaggedBool requireExternalEmailVerificati let ernieZHost = "nginz-https.ernie.example.com" bertZHost = "nginz-https.bert.example.com" + credsWithCertErnie@(_, _, signedCertErnie) <- XMLDSig.mkSignCredsWithCert Nothing 96 + credsWithCertBert@(_, _, signedCertBert) <- XMLDSig.mkSignCredsWithCert Nothing 96 withModifiedBackend def { sparCfg = @@ -59,6 +63,9 @@ testGetSsoCodeByEmailWithMultiIngress (TaggedBool requireExternalEmailVerificati ] ] ) + >=> setField + "idpCertFingerprintAllowlist" + (fingerprintHex <$> [signedCertErnie, signedCertBert]) } $ \domain -> do (owner, tid, _) <- createTeam domain 1 @@ -69,7 +76,7 @@ testGetSsoCodeByEmailWithMultiIngress (TaggedBool requireExternalEmailVerificati assertSuccess =<< setTeamFeatureStatus owner tid "validateSAMLemails" status -- Create IdP for ernie domain - SAML.SampleIdP idpmetaErnie _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmetaErnie _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertErnie idpIdErnie <- createIdpWithZHostV2 owner (Just ernieZHost) idpmetaErnie `bindResponse` \resp -> do resp.status `shouldMatchInt` 201 @@ -77,7 +84,7 @@ testGetSsoCodeByEmailWithMultiIngress (TaggedBool requireExternalEmailVerificati resp.json %. "id" >>= asString -- Create IdP for bert domain - SAML.SampleIdP idpmetaBert _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmetaBert _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertBert idpIdBert <- createIdpWithZHostV2 owner (Just bertZHost) idpmetaBert `bindResponse` \resp -> do resp.status `shouldMatchInt` 201 @@ -169,9 +176,9 @@ testGetSsoCodeByEmailRegular (TaggedBool requireExternalEmailVerification) (Tagg ssoCodeStr <- resp.json %. "sso_code" >>= asString ssoCodeStr `shouldMatch` idpId --- | Test that non-SCIM users get no SSO code -testGetSsoCodeByEmailNonScimUser :: (HasCallStack) => App () -testGetSsoCodeByEmailNonScimUser = do +-- | Test that non-SSO users get no SSO code +testGetSsoCodeByEmailNonSSOUser :: (HasCallStack) => App () +testGetSsoCodeByEmailNonSSOUser = do withModifiedBackend def {sparCfg = setField "enableIdPByEmailDiscovery" True} $ \domain -> do @@ -186,7 +193,7 @@ testGetSsoCodeByEmailNonScimUser = do usr <- randomUser domain def {activate = True} userEmail <- usr %. "email" & asString - -- Try to get SSO code for regular (non-SCIM) user - should return 404 with null + -- Try to get SSO code for regular (non-SSO) user - should return 404 with null getSsoCodeByEmail domain userEmail `bindResponse` \resp -> do resp.status `shouldMatchInt` 404 mbSsoCode <- lookupField resp.json "sso_code" @@ -235,6 +242,7 @@ testGetSsoCodeByEmailDisabledMultiIngress = do let ernieZHost = "nginz-https.ernie.example.com" bertZHost = "nginz-https.bert.example.com" + credsWithCertErnie@(_, _, signedCertErnie) <- XMLDSig.mkSignCredsWithCert Nothing 96 withModifiedBackend def { sparCfg = @@ -259,13 +267,14 @@ testGetSsoCodeByEmailDisabledMultiIngress = do ] ] ) + >=> setField "idpCertFingerprintAllowlist" [fingerprintHex signedCertErnie] } $ \domain -> do (owner, tid, _) <- createTeam domain 1 void $ setTeamFeatureStatus owner tid "sso" "enabled" -- Create IdP for ernie domain - SAML.SampleIdP idpmetaErnie _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmetaErnie _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertErnie idpIdErnie <- createIdpWithZHostV2 owner (Just ernieZHost) idpmetaErnie `bindResponse` \resp -> do resp.status `shouldMatchInt` 201 diff --git a/integration/test/Test/Spar/MultiIngressIdp.hs b/integration/test/Test/Spar/MultiIngressIdp.hs index 58960b5573a..88c1a8853c8 100644 --- a/integration/test/Test/Spar/MultiIngressIdp.hs +++ b/integration/test/Test/Spar/MultiIngressIdp.hs @@ -6,7 +6,9 @@ import Control.Lens ((.~), (^.)) import qualified SAML2.WebSSO.Test.Util as SAML import qualified SAML2.WebSSO.Types as SAML import SetupHelpers +import Testlib.Certs (fingerprintHex) import Testlib.Prelude +import qualified Text.XML.DSig as XMLDSig ernieZHost :: String ernieZHost = "nginz-https.ernie.example.com" @@ -28,6 +30,7 @@ makeSpDomainConfig zhost = testMultiIngressIdpSimpleCase :: (HasCallStack) => App () testMultiIngressIdpSimpleCase = do + credsWithCert@(_, _, signedCert) <- XMLDSig.mkSignCredsWithCert Nothing 96 withModifiedBackend def { sparCfg = @@ -42,13 +45,14 @@ testMultiIngressIdpSimpleCase = do kermitZHost .= makeSpDomainConfig kermitZHost ] ) + >=> setField "idpCertFingerprintAllowlist" [fingerprintHex signedCert] } $ \domain -> do (owner, tid, _) <- createTeam domain 1 void $ setTeamFeatureStatus owner tid "sso" "enabled" -- Create IdP for one domain - SAML.SampleIdP idpmeta _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmeta _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert idpId <- createIdpWithZHostV2 owner (Just ernieZHost) idpmeta `bindResponse` \resp -> do resp.status `shouldMatchInt` 201 @@ -76,6 +80,9 @@ testMultiIngressIdpSimpleCase = do -- multi-ingress domain. testUnconfiguredDomain :: (HasCallStack) => App () testUnconfiguredDomain = forM_ [Nothing, Just kermitZHost] $ \unconfiguredZHost -> do + credsWithCert1@(_, _, signedCert1) <- XMLDSig.mkSignCredsWithCert Nothing 96 + credsWithCert2@(_, _, signedCert2) <- XMLDSig.mkSignCredsWithCert Nothing 96 + credsWithCert3@(_, _, signedCert3) <- XMLDSig.mkSignCredsWithCert Nothing 96 withModifiedBackend def { sparCfg = @@ -85,12 +92,13 @@ testUnconfiguredDomain = forM_ [Nothing, Just kermitZHost] $ \unconfiguredZHost >=> setField "saml.spDomainConfigs" (object [ernieZHost .= makeSpDomainConfig ernieZHost]) + >=> setField "idpCertFingerprintAllowlist" (fingerprintHex <$> [signedCert1, signedCert2, signedCert3]) } $ \domain -> do (owner, tid, _) <- createTeam domain 1 void $ setTeamFeatureStatus owner tid "sso" "enabled" - SAML.SampleIdP idpmeta1 _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmeta1 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert1 idpId1 <- createIdpWithZHostV2 owner (Just ernieZHost) idpmeta1 `bindResponse` \resp -> do resp.status `shouldMatchInt` 201 @@ -116,7 +124,7 @@ testUnconfiguredDomain = forM_ [Nothing, Just kermitZHost] $ \unconfiguredZHost resp.json %. "extraInfo.domain" `shouldMatch` ernieZHost -- Create unconfigured -> no multi-ingress domain - SAML.SampleIdP idpmeta2 _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmeta2 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert2 idpId2 <- createIdpWithZHostV2 owner (unconfiguredZHost) idpmeta2 `bindResponse` \resp -> do resp.status `shouldMatchInt` 201 @@ -128,7 +136,7 @@ testUnconfiguredDomain = forM_ [Nothing, Just kermitZHost] $ \unconfiguredZHost resp.json %. "extraInfo.domain" `shouldMatch` Null -- Create a second unconfigured -> no multi-ingress domain - SAML.SampleIdP idpmeta3 _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmeta3 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert3 idpId3 <- createIdpWithZHostV2 owner (unconfiguredZHost) idpmeta3 `bindResponse` \resp -> do resp.status `shouldMatchInt` 201 @@ -141,6 +149,12 @@ testUnconfiguredDomain = forM_ [Nothing, Just kermitZHost] $ \unconfiguredZHost testMultiIngressAtMostOneIdPPerDomain :: (HasCallStack) => App () testMultiIngressAtMostOneIdPPerDomain = do + credsWithCert1@(_, _, signedCert1) <- XMLDSig.mkSignCredsWithCert Nothing 96 + credsWithCert2@(_, _, signedCert2) <- XMLDSig.mkSignCredsWithCert Nothing 96 + credsWithCert3@(_, _, signedCert3) <- XMLDSig.mkSignCredsWithCert Nothing 96 + credsWithCert4@(_, _, signedCert4) <- XMLDSig.mkSignCredsWithCert Nothing 96 + credsWithCert5@(_, _, signedCert5) <- XMLDSig.mkSignCredsWithCert Nothing 96 + credsWithCert6@(_, _, signedCert6) <- XMLDSig.mkSignCredsWithCert Nothing 96 withModifiedBackend def { sparCfg = @@ -155,28 +169,33 @@ testMultiIngressAtMostOneIdPPerDomain = do kermitZHost .= makeSpDomainConfig kermitZHost ] ) + >=> setField + "idpCertFingerprintAllowlist" + ( fingerprintHex + <$> [signedCert1, signedCert2, signedCert3, signedCert4, signedCert5, signedCert6] + ) } $ \domain -> do (owner, tid, _) <- createTeam domain 1 void $ setTeamFeatureStatus owner tid "sso" "enabled" - SAML.SampleIdP idpmeta1 _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmeta1 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert1 idpId1 <- createIdpWithZHostV2 owner (Just ernieZHost) idpmeta1 `bindResponse` \resp -> do resp.status `shouldMatchInt` 201 resp.json %. "id" >>= asString -- Creating a second IdP for the same domain -> failure - SAML.SampleIdP idpmeta2 _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmeta2 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert2 _idpId2 <- createIdpWithZHostV2 owner (Just ernieZHost) idpmeta2 `bindResponse` \resp -> do resp.status `shouldMatchInt` 409 resp.json %. "label" `shouldMatch` "idp-duplicate-domain-for-team" -- Create an IdP for one domain and update it to another that already has one -> failure - SAML.SampleIdP idpmeta3 _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmeta3 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert3 idpId3 <- - createIdpWithZHostV2 owner (Just bertZHost) idpmeta2 `bindResponse` \resp -> do + createIdpWithZHostV2 owner (Just bertZHost) idpmeta3 `bindResponse` \resp -> do resp.status `shouldMatchInt` 201 resp.json %. "id" >>= asString @@ -186,7 +205,7 @@ testMultiIngressAtMostOneIdPPerDomain = do resp.json %. "label" `shouldMatch` "idp-duplicate-domain-for-team" -- Create an IdP with no domain and update it to a domain that already has one -> failure - SAML.SampleIdP idpmeta4 _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmeta4 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert4 idpId4 <- createIdpWithZHostV2 owner Nothing idpmeta4 `bindResponse` \resp -> do resp.status `shouldMatchInt` 201 @@ -213,7 +232,7 @@ testMultiIngressAtMostOneIdPPerDomain = do deleteIdp owner idpId1 `bindResponse` \resp -> do resp.status `shouldMatchInt` 204 - SAML.SampleIdP idpmeta5 _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmeta5 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert5 idpId5 <- createIdpWithZHostV2 owner (Just ernieZHost) idpmeta5 `bindResponse` \resp -> do resp.status `shouldMatchInt` 201 @@ -221,7 +240,7 @@ testMultiIngressAtMostOneIdPPerDomain = do resp.json %. "id" >>= asString -- After deletion of the IdP of a domain, one can be moved from another domain - SAML.SampleIdP idpmeta6 _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmeta6 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert6 createIdpWithZHostV2 owner (Just bertZHost) idpmeta6 `bindResponse` \resp -> do resp.status `shouldMatchInt` 409 resp.json %. "label" `shouldMatch` "idp-duplicate-domain-for-team" @@ -316,6 +335,11 @@ testNonMultiIngressSetupsCanHaveMoreIdPsPerDomain = do -- practical benefit, this complexity is not justified for now. testMultiIngressIdPIssuerDifferentDomains :: (HasCallStack) => App () testMultiIngressIdPIssuerDifferentDomains = do + credsWithCertV1@(_, _, signedCertV1) <- XMLDSig.mkSignCredsWithCert Nothing 96 + credsWithCertV1_alt@(_, _, signedCertV1_alt) <- XMLDSig.mkSignCredsWithCert Nothing 96 + credsWithCertV1_differentIssuer@(_, _, signedCertV1_differentIssuer) <- XMLDSig.mkSignCredsWithCert Nothing 96 + credsWithCertV2@(_, _, signedCertV2) <- XMLDSig.mkSignCredsWithCert Nothing 96 + credsWithCertV2_alt@(_, _, signedCertV2_alt) <- XMLDSig.mkSignCredsWithCert Nothing 96 withModifiedBackend def { sparCfg = @@ -330,6 +354,16 @@ testMultiIngressIdPIssuerDifferentDomains = do kermitZHost .= makeSpDomainConfig kermitZHost ] ) + >=> setField + "idpCertFingerprintAllowlist" + ( fingerprintHex + <$> [ signedCertV1, + signedCertV1_alt, + signedCertV1_differentIssuer, + signedCertV2, + signedCertV2_alt + ] + ) } $ \domain -> do -- V1 API: Issuers must be unique per backend (across all teams) @@ -337,7 +371,7 @@ testMultiIngressIdPIssuerDifferentDomains = do void $ setTeamFeatureStatus owner1 tid1 "sso" "enabled" -- Create first IdP metadata for V1 - SAML.SampleIdP idpmetaV1 _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmetaV1 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertV1 _idpId1 <- createIdpWithZHostV1 owner1 (Just ernieZHost) idpmetaV1 `bindResponse` \resp -> do resp.status `shouldMatchInt` 201 @@ -350,7 +384,7 @@ testMultiIngressIdPIssuerDifferentDomains = do void $ setTeamFeatureStatus owner2 tid2 "sso" "enabled" -- Try with same domain as original -> should fail (V1 global uniqueness) - SAML.SampleIdP idpmetaV1_alt _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmetaV1_alt _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertV1_alt let idpmetaV1_alt_sameIssuer = idpmetaV1_alt & SAML.edIssuer .~ (idpmetaV1 ^. SAML.edIssuer) createIdpWithZHostV1 owner2 (Just ernieZHost) idpmetaV1_alt_sameIssuer `bindResponse` \resp -> do @@ -368,7 +402,7 @@ testMultiIngressIdPIssuerDifferentDomains = do resp.json %. "label" `shouldMatch` "idp-already-in-use" -- Counter-example: V1 IdP with different issuer -> success - SAML.SampleIdP idpmetaV1_differentIssuer _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmetaV1_differentIssuer _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertV1_differentIssuer void $ createIdpWithZHostV1 owner2 (Just ernieZHost) idpmetaV1_differentIssuer `bindResponse` \resp -> do @@ -380,7 +414,7 @@ testMultiIngressIdPIssuerDifferentDomains = do void $ setTeamFeatureStatus owner3 tid3 "sso" "enabled" -- Create V2 IdP on team 3 with new issuer - SAML.SampleIdP idpmetaV2 _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmetaV2 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertV2 _idpId3 <- createIdpWithZHostV2 owner3 (Just ernieZHost) idpmetaV2 `bindResponse` \resp -> do @@ -390,7 +424,7 @@ testMultiIngressIdPIssuerDifferentDomains = do -- Try to create another V2 IdP on same team with different metadata but same issuer -> failure -- First, try with the same domain -> hits domain constraint (409) - SAML.SampleIdP idpmetaV2_alt _ _ _ <- SAML.makeSampleIdPMetadata + SAML.SampleIdP idpmetaV2_alt _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertV2_alt let idpmetaV2_alt_sameIssuer = idpmetaV2_alt & SAML.edIssuer .~ (idpmetaV2 ^. SAML.edIssuer) createIdpWithZHostV2 owner3 (Just ernieZHost) idpmetaV2_alt_sameIssuer `bindResponse` \resp -> do diff --git a/integration/test/Test/Spar/MultiIngressSSO.hs b/integration/test/Test/Spar/MultiIngressSSO.hs index 65bc8ebdc3b..0d7a0180216 100644 --- a/integration/test/Test/Spar/MultiIngressSSO.hs +++ b/integration/test/Test/Spar/MultiIngressSSO.hs @@ -28,7 +28,9 @@ import qualified Data.Text as T import qualified Data.UUID as UUID import GHC.Stack import qualified SAML2.WebSSO as SAML +import SAML2.WebSSO.Test.Util import SetupHelpers +import Testlib.Certs (fingerprintHex) import qualified Testlib.KleisliXML as KXML import Testlib.Prelude import qualified Text.XML as XML @@ -47,6 +49,8 @@ testMultiIngressSSOGeneralIdp = do bertZHost = "nginz-https.bert.example.com" kermitZHost = "nginz-https.kermit.example.com" + ernieCredsWithCert@(_, _, signedCert) <- SAML.mkSignCredsWithCert Nothing 96 + withModifiedBackend def { sparCfg = @@ -70,13 +74,17 @@ testMultiIngressSSOGeneralIdp = do ] ] ) + >=> setField "idpCertFingerprintAllowlist" [fingerprintHex signedCert] } $ \domain -> do (owner, tid, _) <- createTeam domain 1 void $ setTeamFeatureStatus owner tid "sso" "enabled" - (idp, _idpMeta) <- registerTestIdPWithMetaWithPrivateCreds owner - idpId <- asString $ idp.json %. "id" + SampleIdP idpmeta _pCreds _ _ <- makeSampleIdPMetadataWithCert ernieCredsWithCert + idpId <- + createIdpWithZHostV2 owner Nothing idpmeta `bindResponse` \resp -> do + assertStatus 201 resp + resp.json %. "id" >>= asString _ernieEmail <- ("ernie@" <>) <$> randomDomain checkSPMetadata domain ernieZHost tid @@ -111,6 +119,8 @@ testMultiIngressSSODomainBoundIdp = do bertZHost = "nginz-https.bert.example.com" kermitZHost = "nginz-https.kermit.example.com" + ernieCredsWithCert@(_, _, ernieCert) <- SAML.mkSignCredsWithCert Nothing 96 + withModifiedBackend def { sparCfg = @@ -134,13 +144,18 @@ testMultiIngressSSODomainBoundIdp = do ] ] ) + >=> setField "idpCertFingerprintAllowlist" [fingerprintHex ernieCert] } $ \domain -> do (owner, tid, _) <- createTeam domain 1 void $ setTeamFeatureStatus owner tid "sso" "enabled" - (idp, idpMeta) <- registerTestIdPWithMetaWithPrivateCredsForZHost owner (Just ernieZHost) - idpId <- asString $ idp.json %. "id" + SampleIdP ernieIdpmeta erniePrivCreds _ _ <- makeSampleIdPMetadataWithCert ernieCredsWithCert + idpId <- + createIdpWithZHostV2 owner (Just ernieZHost) ernieIdpmeta `bindResponse` \resp -> do + assertStatus 201 resp + resp.json %. "id" >>= asString + let idpMeta = (ernieIdpmeta, erniePrivCreds) ernieEmail <- ("ernie@" <>) <$> randomDomain checkSPMetadata domain ernieZHost tid diff --git a/integration/test/Testlib/Certs.hs b/integration/test/Testlib/Certs.hs index f0ccdcdfb1a..026c45d494f 100644 --- a/integration/test/Testlib/Certs.hs +++ b/integration/test/Testlib/Certs.hs @@ -27,7 +27,9 @@ import Data.Hourglass import Data.Hourglass.Const import Data.PEM (PEM (PEM), pemWriteBS) import Data.String.Conversions (cs) +import qualified Data.Text as T import Data.X509 +import Data.X509.Extended import Testlib.Prelude type RSAKeyPair = (RSA.PublicKey, RSA.PrivateKey) @@ -139,3 +141,7 @@ mkSignedCert pubKey privKey caName ownerName = certPubKey = PubKeyRSA pubKey, certExtensions = Extensions Nothing } + +-- | Cert's SHA-1 fingerprint in canonical @AA:BB:..@ hex form. +fingerprintHex :: SignedCertificate -> String +fingerprintHex = T.unpack . renderFingerprintHex . certSha1Fingerprint diff --git a/libs/saml2-web-sso/src/SAML2/WebSSO/Test/Util/TestSP.hs b/libs/saml2-web-sso/src/SAML2/WebSSO/Test/Util/TestSP.hs index eab238e7b6d..0ac5f015d25 100644 --- a/libs/saml2-web-sso/src/SAML2/WebSSO/Test/Util/TestSP.hs +++ b/libs/saml2-web-sso/src/SAML2/WebSSO/Test/Util/TestSP.hs @@ -35,6 +35,7 @@ import Data.Time import Data.UUID qualified as UUID import Data.UUID.V4 qualified as UUID import Data.Void (Void) +import Data.X509 qualified as X509 import SAML2.WebSSO as SAML import SAML2.WebSSO.API.Example (GetAllIdPs (..), RequestStore, simpleGetIdPConfigBy, simpleGetIdpIssuer', simpleIsAliveID', simpleStoreID', simpleStoreRequest', simpleUnStoreID', simpleUnStoreRequest') import SAML2.WebSSO.Test.Util.Types @@ -212,12 +213,18 @@ makeTestIdPConfig = do pure (IdPConfig {..}, sampleIdP) makeSampleIdPMetadata :: (HasCallStack) => (MonadIO m, MonadRandom m) => m SampleIdP -makeSampleIdPMetadata = do +makeSampleIdPMetadata = SAML.mkSignCredsWithCert Nothing 96 >>= makeSampleIdPMetadataWithCert + +makeSampleIdPMetadataWithCert :: + (HasCallStack) => + (MonadIO m, MonadRandom m) => + (SignPrivCreds, SignCreds, X509.SignedCertificate) -> + m SampleIdP +makeSampleIdPMetadataWithCert (privcreds, creds, cert) = do issuer <- makeIssuer requri <- do uuid <- UUID.toASCIIBytes <$> liftIO UUID.nextRandom pure $ [uri|https://requri.net/|] & pathL .~ ("/" <> uuid) - (privcreds, creds, cert) <- SAML.mkSignCredsWithCert Nothing 96 pure $ SampleIdP (IdPMetadata issuer requri (NonEmpty.singleton cert)) privcreds creds cert makeIssuer :: (MonadIO m) => m Issuer diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index 655bdd6dc45..9c04f8e7b81 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -64,7 +64,7 @@ import Data.ByteString.Conversion import Data.Domain (Domain, domainText) import Data.HavePendingInvitations import Data.Id -import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as NE import qualified Data.Map as Map import Data.Proxy import Data.Range @@ -450,7 +450,7 @@ authresp mbtid arbody mbHost = do logErrors $ SAML2.authResp mbtid iss rsp go arbody where - go :: NonEmpty SAML.Assertion -> IdP -> SAML.AccessVerdict -> Sem r Void + go :: NE.NonEmpty SAML.Assertion -> IdP -> SAML.AccessVerdict -> Sem r Void go assertions idp verdict = do assertCertsAllowlisted (idp ^. SAML.idpMetadata) case verdict of @@ -813,8 +813,9 @@ idpCreateV7 samlConfig tid zUser idpmeta mReplaces mApiversion mHandle = do -- | Reject IdPs whose cert SHA-1 is not in the configured allowlist. -- --- Empty/absent allowlist is a no-op. On miss: warn-log fingerprint + --- issuer, then throw 'SparIdPCertNotAllowed' (HTTP 403). +-- Empty/absent allowlist is a no-op in the regular case, it short-circuits to +-- error for multi-ingress setups. I.e. the allowlist is required for +-- multi-ingress setups. assertCertsAllowlisted :: ( Member (Input Opts) r, Member (Logger (Msg -> Msg)) r, @@ -824,24 +825,43 @@ assertCertsAllowlisted :: Sem r () assertCertsAllowlisted idpmeta = do mAllow <- inputs idpCertFingerprintAllowlist + samlConfig <- inputs saml + let certs = idpmeta ^. SAML.edCertAuthnResponse + issuerTxt = + TE.decodeUtf8 $ + URI.serializeURIRef' (idpmeta ^. SAML.edIssuer . SAML.fromIssuer) + when (isEmptyAllowList mAllow && SAML.isMultiIngressConfig samlConfig) $ do + let fingerprintHex = renderFingerprintHex . certSha1Fingerprint . NE.head $ certs + logMultiIngressEmptyAllowlist fingerprintHex issuerTxt + throwSparSem (SparIdPCertNotAllowed (T.fromStrict fingerprintHex)) case mAllow of Nothing -> pure () Just (CertFingerprintAllowlist allowed) | Set.null allowed -> pure () | otherwise -> do - let certs = idpmeta ^. SAML.edCertAuthnResponse - issuerTxt = - TE.decodeUtf8 $ - URI.serializeURIRef' (idpmeta ^. SAML.edIssuer . SAML.fromIssuer) forM_ certs $ \c -> do let fingerprint = certSha1Fingerprint c + fingerprintHex = renderFingerprintHex fingerprint unless (Set.member fingerprint allowed) $ do - let fingerprintHex = renderFingerprintHex fingerprint - Logger.warn $ - Log.msg ("Refusing IdP request: cert fingerprint not in allowlist" :: ByteString) - . Log.field "fingerprint" fingerprintHex - . Log.field "issuer" issuerTxt + logCertNotInAllowlist fingerprintHex issuerTxt throwSparSem (SparIdPCertNotAllowed (T.fromStrict fingerprintHex)) + where + logMultiIngressEmptyAllowlist fingerprintHex issuerTxt = + Logger.warn $ + Log.msg ("Refusing IdP request: multi-ingress enabled and allowlist empty" :: ByteString) + . Log.field "fingerprint" fingerprintHex + . Log.field "issuer" issuerTxt + + logCertNotInAllowlist fingerprintHex issuerTxt = + Logger.warn $ + Log.msg ("Refusing IdP request: cert fingerprint not in allowlist" :: ByteString) + . Log.field "fingerprint" fingerprintHex + . Log.field "issuer" issuerTxt + + isEmptyAllowList :: Maybe CertFingerprintAllowlist -> Bool + isEmptyAllowList Nothing = True + isEmptyAllowList (Just (CertFingerprintAllowlist allowed)) | Set.null allowed = True + isEmptyAllowList (Just _) = False -- | Check that issuer is not used anywhere in the system ('WireIdPAPIV1', here it is a -- database key for finding IdPs), or anywhere in this team ('WireIdPAPIV2'), that request @@ -1039,7 +1059,7 @@ idpUpdateXML samlConfig mbZUsr mDomain raw idpmeta idpid mHandle = withDebugLog Log.field fieldName ((intercalate ";; " . map certToString) certs) logCertField _ _ = id - compareNonEmpty :: (Eq a) => NonEmpty a -> NonEmpty a -> ([a], [a]) + compareNonEmpty :: (Eq a) => NE.NonEmpty a -> NE.NonEmpty a -> ([a], [a]) compareNonEmpty xs ys = let l = nub . toList $ xs r = nub . toList $ ys diff --git a/services/spar/test/Test/Spar/Saml/IdPSpec.hs b/services/spar/test/Test/Spar/Saml/IdPSpec.hs index 601a1b381ef..04f221d7f37 100644 --- a/services/spar/test/Test/Spar/Saml/IdPSpec.hs +++ b/services/spar/test/Test/Spar/Saml/IdPSpec.hs @@ -526,8 +526,8 @@ spec = notifs `shouldBe` mempty describe "IdP cert fingerprint allowlist" $ do - let withAllow :: Maybe Spar.Options.CertFingerprintAllowlist -> Spar.Options.Opts - withAllow allow = defaultTestOpts {Spar.Options.idpCertFingerprintAllowlist = allow} + let withAllow :: Spar.Options.Opts -> Maybe Spar.Options.CertFingerprintAllowlist -> Spar.Options.Opts + withAllow opts allow = opts {Spar.Options.idpCertFingerprintAllowlist = allow} generateArbitraryIdPInfo :: IO IdPMetadataInfo generateArbitraryIdPInfo = do @@ -587,32 +587,32 @@ spec = describe "create" $ do it "accepts any cert when allowlist is Nothing" $ do idpInfo <- generateArbitraryIdPInfo - (_logs, res) <- runCreate (withAllow Nothing) idpInfo + (_logs, res) <- runCreate (defaultTestOpts `withAllow` Nothing) idpInfo res `shouldSatisfy` isRight it "accepts any cert when allowlist is empty" $ do idpInfo <- generateArbitraryIdPInfo let empty = Spar.Options.CertFingerprintAllowlist Set.empty - (_logs, res) <- runCreate (withAllow (Just empty)) idpInfo + (_logs, res) <- runCreate (defaultTestOpts `withAllow` (Just empty)) idpInfo res `shouldSatisfy` isRight it "accepts when all fingerprints are allowlisted" $ do idpInfo <- generateArbitraryIdPInfo let allow = allCertsAllowlist idpInfo - (_logs, res) <- runCreate (withAllow (Just allow)) idpInfo + (_logs, res) <- runCreate (defaultTestOpts `withAllow` (Just allow)) idpInfo res `shouldSatisfy` isRight it "accepts when all multi-cert fingerprints are allowlisted" $ do idpInfo <- generateTwoCertIdPInfo let allow = allCertsAllowlist idpInfo - (_logs, res) <- runCreate (withAllow (Just allow)) idpInfo + (_logs, res) <- runCreate (defaultTestOpts `withAllow` (Just allow)) idpInfo res `shouldSatisfy` isRight it "rejects when no fingerprint matches and logs the refusal" $ do idpInfo@(IdPMetadataValue _ m) <- generateArbitraryIdPInfo let allow = singletonAllowlist bogusFingerprint fingerprint = certToFingerprint . NonEmptyL.head $ m._edCertAuthnResponse - (logs, res) <- runCreate (withAllow (Just allow)) idpInfo + (logs, res) <- runCreate (defaultTestOpts `withAllow` (Just allow)) idpInfo res `shouldBe` Left (SAML.CustomError (SparIdPCertNotAllowed fingerprint)) let logged = TL.decodeUtf8 $ LBS.concat (map snd logs) logged `shouldSatisfy` (("cert fingerprint not in allowlist, fingerprint=" <> fingerprint) `TL.isInfixOf`) @@ -621,50 +621,85 @@ spec = idpInfo@(IdPMetadataValue _ m) <- generateTwoCertIdPInfo let allow = singletonAllowlist (firstCertFingerprint idpInfo) secondCertFingerprint = certToFingerprint . head . NonEmptyL.tail $ m._edCertAuthnResponse - (logs, res) <- runCreate (withAllow (Just allow)) idpInfo + (logs, res) <- runCreate (defaultTestOpts `withAllow` (Just allow)) idpInfo res `shouldBe` Left (SAML.CustomError (SparIdPCertNotAllowed secondCertFingerprint)) let logged = TL.decodeUtf8 $ LBS.concat (map snd logs) logged `shouldSatisfy` (("cert fingerprint not in allowlist, fingerprint=" <> secondCertFingerprint) `TL.isInfixOf`) + describe "multi-ingress" $ do + it "accepts when all fingerprints are allowlisted" $ do + idpInfo <- generateArbitraryIdPInfo + let allow = allCertsAllowlist idpInfo + optsWithMultiIngress = + defaultTestOpts + { Spar.Options.saml = multiIngressSamlConfig, + Spar.Options.idpCertFingerprintAllowlist = Just allow + } + (_logs, _notifs, res) <- + interpretWithLoggingMockOptsE optsWithMultiIngress Nothing $ + idpCreate multiIngressSamlConfig tid zUser miHost1 idpInfo Nothing apiVersionV2 idpHandle + res `shouldSatisfy` isRight + + let testRejectEmptyAllowlist testName allowlist = + it testName $ do + idpInfo@(IdPMetadataValue _ m) <- generateArbitraryIdPInfo + let optsWithMultiIngress = + defaultTestOpts + { Spar.Options.saml = multiIngressSamlConfig, + Spar.Options.idpCertFingerprintAllowlist = allowlist + } + fingerprint = certToFingerprint . NonEmptyL.head $ m._edCertAuthnResponse + (logs, _notifs, res) <- + interpretWithLoggingMockOptsE optsWithMultiIngress Nothing $ + idpCreate multiIngressSamlConfig tid zUser miHost1 idpInfo Nothing apiVersionV2 idpHandle + res `shouldBe` Left (SAML.CustomError (SparIdPCertNotAllowed fingerprint)) + let logged = TL.decodeUtf8 $ LBS.concat (map snd logs) + logged `shouldSatisfy` (("Refusing IdP request: multi-ingress enabled and allowlist empty" :: TL.Text) `TL.isInfixOf`) + + testRejectEmptyAllowlist "rejects when allowlist is Nothing" Nothing + testRejectEmptyAllowlist + "rejects when allowlist is empty" + (Just (Spar.Options.CertFingerprintAllowlist Set.empty)) + describe "update" $ do it "accepts any cert when allowlist is Nothing" $ do idpInfoCrt <- generateArbitraryIdPInfo idpInfoUpd <- generateArbitraryIdPInfo - (_logs, res) <- runCreateUpdate (withAllow Nothing) idpInfoCrt idpInfoUpd + (_logs, res) <- runCreateUpdate (defaultTestOpts `withAllow` Nothing) idpInfoCrt idpInfoUpd res `shouldSatisfy` isRight it "accepts any cert when allowlist is empty" $ do idpInfoCrt <- generateArbitraryIdPInfo idpInfoUpd <- generateArbitraryIdPInfo - (_logs, res) <- runCreateUpdate (withAllow (Just mempty)) idpInfoCrt idpInfoUpd + (_logs, res) <- runCreateUpdate (defaultTestOpts `withAllow` (Just mempty)) idpInfoCrt idpInfoUpd res `shouldSatisfy` isRight it "accepts when all fingerprints are allowlisted" $ do idpInfoCrt <- generateArbitraryIdPInfo idpInfoUpd <- generateArbitraryIdPInfo let allow = allCertsAllowlist idpInfoCrt <> allCertsAllowlist idpInfoUpd - (_logs, res) <- runCreateUpdate (withAllow (Just allow)) idpInfoCrt idpInfoUpd + (_logs, res) <- runCreateUpdate (defaultTestOpts `withAllow` (Just allow)) idpInfoCrt idpInfoUpd res `shouldSatisfy` isRight it "accepts when all multi-cert fingerprints are allowlisted" $ do idpInfoCrt <- generateTwoCertIdPInfo idpInfoUpd <- generateTwoCertIdPInfo let allow = allCertsAllowlist idpInfoCrt <> allCertsAllowlist idpInfoUpd - (_logs, res) <- runCreateUpdate (withAllow (Just allow)) idpInfoCrt idpInfoUpd + (_logs, res) <- runCreateUpdate (defaultTestOpts `withAllow` (Just allow)) idpInfoCrt idpInfoUpd res `shouldSatisfy` isRight it "rejects when no fingerprint matches" $ do idpInfo@(IdPMetadataValue _ m) <- generateArbitraryIdPInfo let fingerprint = certToFingerprint . NonEmptyL.head $ m._edCertAuthnResponse (_logs1, _notifs1, createdE) <- - interpretWithLoggingMockOptsE (withAllow Nothing) Nothing $ + interpretWithLoggingMockOptsE (defaultTestOpts `withAllow` Nothing) Nothing $ idpCreate singleIngressSamlConfig tid zUser host idpInfo Nothing apiVersionV2 idpHandle case createdE of Left e -> expectationFailure ("unexpected create failure: " <> show e) Right idp -> do let allow = singletonAllowlist bogusFingerprint (_logs2, _notifs2, res) <- - interpretWithLoggingMockOptsE (withAllow (Just allow)) Nothing $ do + interpretWithLoggingMockOptsE (defaultTestOpts `withAllow` (Just allow)) Nothing $ do insertConfig idp idpUpdate singleIngressSamlConfig zUser host idpInfo (idp._idpId) Nothing res `shouldBe` Left (SAML.CustomError (SparIdPCertNotAllowed fingerprint)) @@ -674,17 +709,69 @@ spec = let partialAllow = singletonAllowlist (firstCertFingerprint idpInfo) secondCertFingerprint = certToFingerprint . head . NonEmptyL.tail $ m._edCertAuthnResponse (_logs1, _notifs1, createdE) <- - interpretWithLoggingMockOptsE (withAllow Nothing) Nothing $ + interpretWithLoggingMockOptsE (defaultTestOpts `withAllow` Nothing) Nothing $ idpCreate singleIngressSamlConfig tid zUser host idpInfo Nothing apiVersionV2 idpHandle case createdE of Left e -> expectationFailure ("unexpected create failure: " <> show e) Right idp -> do (_logs2, _notifs2, res) <- - interpretWithLoggingMockOptsE (withAllow (Just partialAllow)) Nothing $ do + interpretWithLoggingMockOptsE (defaultTestOpts `withAllow` (Just partialAllow)) Nothing $ do insertConfig idp idpUpdate singleIngressSamlConfig zUser host idpInfo (idp._idpId) Nothing res `shouldBe` Left (SAML.CustomError (SparIdPCertNotAllowed secondCertFingerprint)) + describe "multi-ingress" $ do + it "accepts when all fingerprints are allowlisted" $ do + idpInfoCrt <- generateArbitraryIdPInfo + idpInfoUpd <- generateArbitraryIdPInfo + let allow = allCertsAllowlist idpInfoCrt <> allCertsAllowlist idpInfoUpd + optsWithMultiIngress = + defaultTestOpts + { Spar.Options.saml = multiIngressSamlConfig, + Spar.Options.idpCertFingerprintAllowlist = Just allow + } + (_logs, _notifs, res) <- + interpretWithLoggingMockOptsE optsWithMultiIngress Nothing $ do + idp <- idpCreate multiIngressSamlConfig tid zUser miHost1 idpInfoCrt Nothing apiVersionV2 idpHandle + idpUpdate multiIngressSamlConfig zUser miHost1 idpInfoUpd (idp._idpId) Nothing + res `shouldSatisfy` isRight + + let testRejectEmptyAllowlistOnUpdate testName updateAllowlist = + it testName $ do + idpInfoCrt <- generateArbitraryIdPInfo + idpInfoUpd@(IdPMetadataValue _ m) <- generateArbitraryIdPInfo + let allowCrt = allCertsAllowlist idpInfoCrt + fingerprint = certToFingerprint . NonEmptyL.head $ m._edCertAuthnResponse + optsForCreate = + defaultTestOpts + { Spar.Options.saml = multiIngressSamlConfig, + Spar.Options.idpCertFingerprintAllowlist = Just allowCrt + } + optsForUpdate = + defaultTestOpts + { Spar.Options.saml = multiIngressSamlConfig, + Spar.Options.idpCertFingerprintAllowlist = updateAllowlist + } + (_logsCrt, _notifsCrt, createE) <- + interpretWithLoggingMockOptsE optsForCreate Nothing $ + idpCreate multiIngressSamlConfig tid zUser miHost1 idpInfoCrt Nothing apiVersionV2 idpHandle + case createE of + Left e -> expectationFailure ("unexpected create failure: " <> show e) + Right idp -> do + (logsUpd, _notifsUpd, updateE) <- + interpretWithLoggingMockOptsE optsForUpdate Nothing $ do + insertConfig idp + idpUpdate multiIngressSamlConfig zUser miHost1 idpInfoUpd (idp._idpId) Nothing + updateE `shouldBe` Left (SAML.CustomError (SparIdPCertNotAllowed fingerprint)) + let logged = TL.decodeUtf8 $ LBS.concat (map snd logsUpd) + logged `shouldSatisfy` (("Refusing IdP request: multi-ingress enabled and allowlist empty" :: TL.Text) `TL.isInfixOf`) + + testRejectEmptyAllowlistOnUpdate "rejects when allowlist is Nothing" Nothing + + testRejectEmptyAllowlistOnUpdate + "rejects when allowlist is empty" + (Just (Spar.Options.CertFingerprintAllowlist Set.empty)) + describe "authresp" $ do let makeIdp :: IdPMetadataInfo -> IO IdP makeIdp (IdPMetadataValue _ metadata) = do @@ -720,7 +807,7 @@ spec = raw <- generate (arbitrary :: Gen (MultipartData Mem)) let dummyBody = either error id (fromMultipart raw :: Either String SAML.AuthnResponseBody) user <- generate arbitrary - let opts = withAllow allow + let opts = defaultTestOpts `withAllow` allow uref = SAML.UserRef m._edIssuer (SAML.unspecifiedNameID "test-user") user' = user {userTeam = Just authrspTestTeamId} verdict = SAML.AccessGranted uref @@ -800,6 +887,53 @@ spec = logged `shouldNotSatisfy` ("cert fingerprint not in allowlist" `TL.isInfixOf`) other -> expectationFailure $ "expected CustomServant (VerifyHandlerGranted), got: " <> show other + describe "multi-ingress" $ do + let makeAuthRespRequestMultiIngress teamIdParam idpInfo@(IdPMetadataValue _ m) allow = do + idp <- makeIdp idpInfo + ass <- makeControlledAssertion + raw <- generate (arbitrary :: Gen (MultipartData Mem)) + let dummyBody = either error id (fromMultipart raw :: Either String SAML.AuthnResponseBody) + user <- generate arbitrary + let opts = + (defaultTestOpts `withAllow` allow) + { Spar.Options.saml = multiIngressSamlConfig + } + uref = SAML.UserRef m._edIssuer (SAML.unspecifiedNameID "test-user") + user' = user {userTeam = Just authrspTestTeamId} + verdict = SAML.AccessGranted uref + interpretAuthrespE opts (Just user') (ass NonEmptyL.:| [], idp, verdict) $ do + SAMLUserStore.insert uref (userId user') + authresp teamIdParam dummyBody (Just miDomain1) + + it ("accepts when all fingerprints are allowlisted - teamId param " <> show requestParamTeamId) $ do + idpInfo <- generateArbitraryIdPInfo + let allow = allCertsAllowlist idpInfo + (logs, res) <- makeAuthRespRequestMultiIngress requestParamTeamId idpInfo $ Just allow + case res of + Left (SAML.CustomServant servantErr) -> do + errHTTPCode servantErr `shouldBe` 200 + errReasonPhrase servantErr `shouldBe` "success" + let logged = TL.decodeUtf8 $ LBS.concat (map snd logs) + logged `shouldNotSatisfy` ("cert fingerprint not in allowlist" `TL.isInfixOf`) + other -> expectationFailure $ "expected success, got: " <> show other + + let testRejectEmptyAllowlistAuthresp testName allowlist = + it (testName <> " - teamId param " <> show requestParamTeamId) $ do + idpInfo <- generateArbitraryIdPInfo + (logs, res) <- makeAuthRespRequestMultiIngress requestParamTeamId idpInfo allowlist + case res of + Left (SAML.CustomServant servantErr) -> do + errHTTPCode servantErr `shouldBe` 403 + errReasonPhrase servantErr `shouldBe` "idp-cert-not-allowed" + let logged = TL.decodeUtf8 $ LBS.concat (map snd logs) + logged `shouldSatisfy` (("Refusing IdP request: multi-ingress enabled and allowlist empty" :: TL.Text) `TL.isInfixOf`) + other -> expectationFailure $ "expected idp-cert-not-allowed error, got: " <> show other + + testRejectEmptyAllowlistAuthresp "rejects when allowlist is Nothing" Nothing + testRejectEmptyAllowlistAuthresp + "rejects when allowlist is empty" + (Just (Spar.Options.CertFingerprintAllowlist Set.empty)) + type LogLine = (Level, LByteString) interpretWithLoggingMock :: From b765f7c75bff214073f29a66d3c39bc9cdea05d1 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Fri, 10 Jul 2026 11:26:52 +0200 Subject: [PATCH 005/113] test(mls): free connections and cap retries in createMLSOne2OnePartner (#5329) --- integration/test/SetupHelpers.hs | 43 ++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/integration/test/SetupHelpers.hs b/integration/test/SetupHelpers.hs index eedfa53c8c9..c80a942b13d 100644 --- a/integration/test/SetupHelpers.hs +++ b/integration/test/SetupHelpers.hs @@ -235,23 +235,34 @@ createMLSOne2OnePartner :: user -> convDomain -> App Value -createMLSOne2OnePartner domain other convDomain = loop +createMLSOne2OnePartner domain other convDomain = do + desiredConvDomain <- make convDomain & asString + go (0 :: Int) desiredConvDomain where - loop = do - u <- randomUser domain def - connectTwoUsers u other - apiVersion <- getAPIVersionFor domain - conv <- - if apiVersion < 6 - then getMLSOne2OneConversation other u >>= getJSON 200 - else getMLSOne2OneConversation other u >>= getJSON 200 >>= (%. "conversation") - - desiredConvDomain <- make convDomain & asString - actualConvDomain <- conv %. "qualified_id.domain" & asString - - if desiredConvDomain == actualConvDomain - then pure u - else loop + maxAttempts = 128 + go n desiredConvDomain + | n >= maxAttempts = + assertFailure $ + "createMLSOne2OnePartner: gave up after " + <> show maxAttempts + <> " attempts to place the 1-1 conversation on domain " + <> desiredConvDomain + | otherwise = do + u <- randomUser domain def + connectTwoUsers u other + apiVersion <- getAPIVersionFor domain + conv <- + if apiVersion < 6 + then getMLSOne2OneConversation other u >>= getJSON 200 + else getMLSOne2OneConversation other u >>= getJSON 200 >>= (%. "conversation") + + actualConvDomain <- conv %. "qualified_id.domain" & asString + + if desiredConvDomain == actualConvDomain + then pure u + else do + putConnection other u "blocked" >>= assertSuccess + go (n + 1) desiredConvDomain -- Copied from `src/CargoHold/API/V3.hs` and inlined to avoid pulling in `types-common` randomToken :: (HasCallStack) => App String From dee60f6fd8164609d4a6749b3acd27ec09319c27 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 10 Jul 2026 14:37:12 +0200 Subject: [PATCH 006/113] WPB-26487 backend background jobs hasql resource pool refactoring (#5323) Refactors the PostgreSQL connection pooling across multiple services/libraries by switching from hasql-pool to hasql-resource-pool, introducing a new Hasql.Pool.Extended.Pool wrapper that carries metrics and a rawPool accessor for code paths that still need the underlying pool (notably migrations). Changes: - Replace hasql-pool with hasql-resource-pool across Cabal and Nix, including pin/override updates. - Update service/library effect stacks to use Hasql.Pool.Extended.Pool (and rawPool where a raw Hasql.Pool.Pool is required). - Rework pool metrics collection (session/acquisition latency histograms + periodic pool stats snapshots). --- changelog.d/0-release-notes/WPB-25325 | 3 + .../templates/migrate-data.yaml | 2 - charts/elasticsearch-index/values.yaml | 1 - charts/wire-server/values.yaml | 3 - .../src/developer/reference/config-options.md | 11 - flake.lock | 18 ++ flake.nix | 7 + libs/extended/default.nix | 6 +- libs/extended/extended.cabal | 3 +- libs/extended/src/Hasql/Pool/Extended.hs | 212 ++++++++++-------- libs/types-common/src/Data/Misc.hs | 12 + libs/wire-subsystems/default.nix | 6 +- .../src/Wire/AppStore/Postgres.hs | 33 +-- .../src/Wire/CodeStore/Migration.hs | 2 +- .../src/Wire/ConversationStore/Migration.hs | 5 +- .../src/Wire/ConversationStore/Postgres.hs | 8 +- .../Wire/DomainRegistrationStore/Migration.hs | 5 +- libs/wire-subsystems/src/Wire/Error.hs | 9 +- .../src/Wire/MeetingsStore/Postgres.hs | 17 +- .../wire-subsystems/src/Wire/MigrationLock.hs | 3 +- libs/wire-subsystems/src/Wire/Postgres.hs | 58 ++++- .../src/Wire/PostgresMigrations.hs | 8 +- .../Wire/TeamCollaboratorsStore/Postgres.hs | 31 +-- .../src/Wire/TeamFeatureStore/Migration.hs | 5 +- .../src/Wire/UserGroupStore/Postgres.hs | 7 +- libs/wire-subsystems/wire-subsystems.cabal | 2 +- nix/haskell-pins.nix | 7 + nix/manual-overrides.nix | 4 +- .../background-worker/background-worker.cabal | 2 +- .../background-worker.integration.yaml | 1 - services/background-worker/default.nix | 4 +- .../src/Wire/BackgroundWorker/Env.hs | 2 +- .../background-worker/src/Wire/Effects.hs | 6 +- services/brig/brig.cabal | 2 +- services/brig/brig.integration.yaml | 1 - services/brig/default.nix | 4 +- services/brig/src/Brig/App.hs | 2 +- .../brig/src/Brig/CanonicalInterpreter.hs | 4 +- services/brig/src/Brig/Index/Eval.hs | 17 +- services/brig/src/Brig/Index/Options.hs | 7 - services/brig/src/Brig/Run.hs | 5 +- services/galley/default.nix | 4 +- services/galley/galley.cabal | 2 +- services/galley/galley.integration.yaml | 1 - services/galley/src/Galley/App.hs | 3 +- services/galley/src/Galley/Env.hs | 2 +- services/galley/src/Galley/Run.hs | 3 +- 47 files changed, 300 insertions(+), 260 deletions(-) create mode 100644 changelog.d/0-release-notes/WPB-25325 diff --git a/changelog.d/0-release-notes/WPB-25325 b/changelog.d/0-release-notes/WPB-25325 new file mode 100644 index 00000000000..e9526beeb71 --- /dev/null +++ b/changelog.d/0-release-notes/WPB-25325 @@ -0,0 +1,3 @@ +The PostgreSQL connection pool implementation was switched to `hasql-resource-pool`. +The `agingTimeout` setting is now ignored and should be treated as deprecated. +Pool metrics now include acquisition/session latency. diff --git a/charts/elasticsearch-index/templates/migrate-data.yaml b/charts/elasticsearch-index/templates/migrate-data.yaml index ec23f91f8ca..bb59d83ff6d 100644 --- a/charts/elasticsearch-index/templates/migrate-data.yaml +++ b/charts/elasticsearch-index/templates/migrate-data.yaml @@ -61,8 +61,6 @@ spec: - {{ .Values.postgresqlPool.size | quote }} - --pg-pool-acquisition-timeout - {{ .Values.postgresqlPool.acquisitionTimeout | quote }} - - --pg-pool-aging-timeout - - {{ .Values.postgresqlPool.agingTimeout | quote }} - --pg-pool-idleness-timeout - {{ .Values.postgresqlPool.idlenessTimeout | quote }} {{- if hasKey $.Values.secrets "pgPassword" }} diff --git a/charts/elasticsearch-index/values.yaml b/charts/elasticsearch-index/values.yaml index 9aa145388fe..b12673ed181 100644 --- a/charts/elasticsearch-index/values.yaml +++ b/charts/elasticsearch-index/values.yaml @@ -46,7 +46,6 @@ postgresql: postgresqlPool: size: 100 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m postgresMigration: diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml index a4468824384..59274ebf5fc 100644 --- a/charts/wire-server/values.yaml +++ b/charts/wire-server/values.yaml @@ -71,7 +71,6 @@ galley: postgresqlPool: size: 100 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m # Not used if enableFederation is false @@ -964,7 +963,6 @@ background-worker: postgresqlPool: size: 5 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m # Setting this to `true` will start conversation migration to postgresql. @@ -1128,7 +1126,6 @@ brig: postgresqlPool: size: 100 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m emailSMS: diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index 030b4476426..407e7d9ef4e 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -1944,15 +1944,6 @@ config: # Connection acquisition timeout. acquisitionTimeout: 10s - # Maximal connection lifetime. - # - # Determines how long is available for reuse. After the timeout passes and - # an active session is finished the connection will be closed releasing a - # slot in the pool for a fresh connection to be established. - # - # This is useful as a healthy measure for resetting the server-side caches. - agingTimeout: 1d - # Maximal connection idle time. idlenessTimeout: 10m secrets: @@ -1970,7 +1961,6 @@ postgresql: postgresqlPool: size: 100 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m postgresqlPassword: /path/to/pgPassword # refers to a PostgreSQL password file ``` @@ -2199,7 +2189,6 @@ postgresql: postgresqlPool: size: 5 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m # Start migration workers when true diff --git a/flake.lock b/flake.lock index ae1e0b1c673..f4c3b235f89 100644 --- a/flake.lock +++ b/flake.lock @@ -215,6 +215,23 @@ "type": "github" } }, + "hasql-resource-pool": { + "flake": false, + "locked": { + "lastModified": 1783610992, + "narHash": "sha256-xpnT7V2EddKVzK5cvaPeRYpCyRbWyqkQJrsBMpSbWXg=", + "owner": "wireapp", + "repo": "hasql-resource-pool", + "rev": "5b5d3df0fff81801986a0110acae5420215f01c5", + "type": "github" + }, + "original": { + "owner": "wireapp", + "repo": "hasql-resource-pool", + "rev": "5b5d3df0fff81801986a0110acae5420215f01c5", + "type": "github" + } + }, "hsaml2": { "flake": false, "locked": { @@ -356,6 +373,7 @@ "cryptostore": "cryptostore", "flake-utils": "flake-utils", "hasql-migration": "hasql-migration", + "hasql-resource-pool": "hasql-resource-pool", "hsaml2": "hsaml2", "hspec-wai": "hspec-wai", "http-client": "http-client", diff --git a/flake.nix b/flake.nix index bbc3c8826f9..5206ca874e8 100644 --- a/flake.nix +++ b/flake.nix @@ -101,6 +101,13 @@ url = "github:wireapp/hsaml2/use-crypton-asn1"; flake = false; }; + + hasql-resource-pool = { + # Update this to the upstream repo/rev once the PR is merged there. + # https://github.com/avanov/hasql-resource-pool/pull/6 + url = "github:wireapp/hasql-resource-pool?rev=5b5d3df0fff81801986a0110acae5420215f01c5"; + flake = false; + }; }; outputs = inputs@{ nixpkgs, nixpkgs_24_11, flake-utils, tom-bombadil, sbomnix, ... }: diff --git a/libs/extended/default.nix b/libs/extended/default.nix index 4dd312bbaa6..1813326fc12 100644 --- a/libs/extended/default.nix +++ b/libs/extended/default.nix @@ -19,7 +19,7 @@ , errors , exceptions , hasql -, hasql-pool +, hasql-resource-pool , hspec , hspec-discover , http-client @@ -49,7 +49,6 @@ , transformers , types-common , unliftio -, uuid , wai }: mkDerivation { @@ -72,7 +71,7 @@ mkDerivation { errors exceptions hasql - hasql-pool + hasql-resource-pool http-client http-client-tls http-types @@ -96,7 +95,6 @@ mkDerivation { transformers types-common unliftio - uuid wai ]; testHaskellDepends = [ diff --git a/libs/extended/extended.cabal b/libs/extended/extended.cabal index 0174d4b10cc..eee2d884228 100644 --- a/libs/extended/extended.cabal +++ b/libs/extended/extended.cabal @@ -103,7 +103,7 @@ library , errors , exceptions , hasql - , hasql-pool + , hasql-resource-pool , http-client , http-client-tls , http-types @@ -127,7 +127,6 @@ library , transformers , types-common , unliftio - , uuid , wai default-language: GHC2021 diff --git a/libs/extended/src/Hasql/Pool/Extended.hs b/libs/extended/src/Hasql/Pool/Extended.hs index cf6e6d0f4f1..0cd8e4b61c9 100644 --- a/libs/extended/src/Hasql/Pool/Extended.hs +++ b/libs/extended/src/Hasql/Pool/Extended.hs @@ -20,21 +20,22 @@ module Hasql.Pool.Extended where import Data.Aeson import Data.Map as Map import Data.Misc -import Data.Set qualified as Set -import Data.UUID +import Hasql.Connection qualified import Hasql.Connection.Settings qualified as HasqlConnSettings -import Hasql.Pool as HasqlPool -import Hasql.Pool.Config qualified as HasqlPool -import Hasql.Pool.Observation +import Hasql.Pool qualified as HasqlPool import Imports import PostgresqlConnectionString qualified import Prometheus +import UnliftIO.IO (getMonotonicTime) import Util.Options data PoolConfig = PoolConfig { size :: Int, + -- | Configured pool acquisition wait time. hasql-resource-pool only + -- accepts whole seconds here, so we round up to the nearest second and + -- pass it through as the pool acquisition timeout. acquisitionTimeout :: Duration, - agingTimeout :: Duration, + -- | Controls how long idle connections stay resident in the pool. idlenessTimeout :: Duration } deriving (Eq, Show) @@ -44,100 +45,127 @@ instance FromJSON PoolConfig where PoolConfig <$> o .: "size" <*> o .: "acquisitionTimeout" - <*> o .: "agingTimeout" <*> o .: "idlenessTimeout" --- | Creates a pool from postgres config params --- --- HasqlPool.staticConnectionSettings translates the connection settings to the pool settings --- HasqlPool.settings translates the pool settings into pool config --- HasqlPool.acquire creates the pool. --- ezpz. -initPostgresPool :: PoolConfig -> Map Text Text -> Maybe FilePathSecrets -> IO HasqlPool.Pool -initPostgresPool config pgConfig mFpSecrets = do - mPw <- for mFpSecrets initCredentials - let pgSettings = - HasqlConnSettings.connectionString (PostgresqlConnectionString.toUrl $ PostgresqlConnectionString.fromKeyValueParams pgConfig) - <> foldMap HasqlConnSettings.password mPw - metrics <- initHasqlPoolMetrics - connsRef <- newIORef $ Connections mempty mempty mempty - HasqlPool.acquire $ - HasqlPool.settings - [ HasqlPool.staticConnectionSettings pgSettings, - HasqlPool.size config.size, - HasqlPool.acquisitionTimeout config.acquisitionTimeout.duration, - HasqlPool.agingTimeout config.agingTimeout.duration, - HasqlPool.idlenessTimeout config.idlenessTimeout.duration, - HasqlPool.observationHandler (observationHandler connsRef metrics) - ] - data HasqlPoolMetrics = HasqlPoolMetrics { readyForUseGauge :: Gauge, inUseGauge :: Gauge, establishedCounter :: Counter, - terminationCounter :: Counter, + connectionFailureCounter :: Counter, + acquisitionTimeoutCounter :: Counter, sessionFailureCounter :: Counter, - sessionCounter :: Counter + sessionCounter :: Counter, + connectionAcquisitionDuration :: Histogram, + sessionDuration :: Histogram } -initHasqlPoolMetrics :: IO HasqlPoolMetrics -initHasqlPoolMetrics = do - HasqlPoolMetrics - <$> register (gauge $ Info "wire_hasql_pool_ready_for_use" "Number of hasql pool connections ready for use") - <*> register (gauge $ Info "wire_hasql_pool_in_use" "Number of hasql pool connections ready for use") - <*> register (counter $ Info "wire_hasql_pool_connection_established_count" "Number of established connections") - <*> register (counter $ Info "wire_hasql_pool_connection_terminated_count" "Number of terminated connections") - <*> register (counter $ Info "wire_hasql_pool_session_failure_count" "Number of times a session has failed") - <*> register (counter $ Info "wire_hasql_pool_session_count" "Number of times a session was created") - -data Connections = Connections - { connecting :: Set UUID, - inUse :: Set UUID, - readyForUse :: Set UUID +data Pool = Pool + { rawPool :: HasqlPool.Pool, + metrics :: HasqlPoolMetrics, + -- | Pool acquisition timeout in seconds, rounded up from the configured + -- duration. This is used by the session runner to bound waiting for an + -- available connection slot. + poolAcquisitionTimeout :: Duration } -observationHandler :: IORef Connections -> HasqlPoolMetrics -> Observation -> IO () -observationHandler connsRef metrics (ConnectionObservation connId status) = do - case status of - ConnectingConnectionStatus -> do - modifyIORef' connsRef (\conns -> conns {connecting = Set.insert connId conns.connecting}) - ReadyForUseConnectionStatus reason -> do - connsChange <- case reason of - SessionFailedConnectionReadyForUseReason _ -> do - incCounter metrics.sessionFailureCounter - pure $ \conns -> conns {inUse = Set.delete connId conns.inUse} - SessionSucceededConnectionReadyForUseReason -> do - pure $ \conns -> conns {inUse = Set.delete connId conns.inUse} - EstablishedConnectionReadyForUseReason -> do - incCounter metrics.establishedCounter - pure (\conns -> conns {connecting = Set.delete connId conns.connecting}) - - (inUseSize, readyForUseSize) <- atomicModifyIORef' connsRef $ \conns -> - let newConns = (connsChange conns) {readyForUse = Set.insert connId conns.readyForUse} - in (newConns, (Set.size newConns.inUse, Set.size newConns.readyForUse)) - - setGauge metrics.readyForUseGauge (fromIntegral readyForUseSize) - setGauge metrics.inUseGauge (fromIntegral inUseSize) - InUseConnectionStatus -> do - incCounter metrics.sessionCounter - (inUseSize, readyForUseSize) <- atomicModifyIORef' connsRef $ \conns -> - let newConns = - conns - { readyForUse = Set.delete connId conns.readyForUse, - inUse = Set.insert connId conns.inUse - } - in (newConns, (Set.size newConns.inUse, Set.size newConns.readyForUse)) - setGauge metrics.readyForUseGauge (fromIntegral readyForUseSize) - setGauge metrics.inUseGauge (fromIntegral inUseSize) - TerminatedConnectionStatus _ -> do - (inUseSize, readyForUseSize) <- atomicModifyIORef' connsRef $ \conns -> - let newConns = - conns - { connecting = Set.delete connId conns.connecting, - readyForUse = Set.delete connId conns.readyForUse, - inUse = Set.delete connId conns.inUse - } - in (newConns, (Set.size newConns.inUse, Set.size newConns.readyForUse)) - incCounter metrics.terminationCounter - setGauge metrics.readyForUseGauge (fromIntegral readyForUseSize) - setGauge metrics.inUseGauge (fromIntegral inUseSize) +recordHasqlPoolConnectionAcquisition :: HasqlPoolMetrics -> Double -> IO () +recordHasqlPoolConnectionAcquisition metrics secs = + observe metrics.connectionAcquisitionDuration secs + +recordHasqlPoolConnectionEstablished :: HasqlPoolMetrics -> IO () +recordHasqlPoolConnectionEstablished metrics = + void $ addCounter metrics.establishedCounter 1 + +recordHasqlPoolConnectionFailure :: HasqlPoolMetrics -> IO () +recordHasqlPoolConnectionFailure metrics = + void $ addCounter metrics.connectionFailureCounter 1 + +recordHasqlPoolSessionStarted :: HasqlPoolMetrics -> IO () +recordHasqlPoolSessionStarted metrics = + void $ addCounter metrics.sessionCounter 1 + +recordHasqlPoolSessionFailure :: HasqlPoolMetrics -> IO () +recordHasqlPoolSessionFailure metrics = + void $ addCounter metrics.sessionFailureCounter 1 + +recordHasqlPoolSessionDuration :: HasqlPoolMetrics -> Double -> IO () +recordHasqlPoolSessionDuration metrics secs = + observe metrics.sessionDuration secs + +recordHasqlPoolAcquisitionTimeout :: HasqlPoolMetrics -> IO () +recordHasqlPoolAcquisitionTimeout metrics = + void $ addCounter metrics.acquisitionTimeoutCounter 1 + +recordHasqlPoolStats :: Pool -> IO () +recordHasqlPoolStats pool = do + -- hasql-resource-pool does not expose per-acquire/release callbacks, so + -- these gauges are refreshed from the pool's current total connections stats instead. + poolStats <- HasqlPool.stats pool.rawPool + setGauge pool.metrics.readyForUseGauge (fromIntegral poolStats.available) + setGauge pool.metrics.inUseGauge (fromIntegral poolStats.currentUsage) + +startHasqlPoolStatsReporter :: Pool -> IO () +startHasqlPoolStatsReporter pool = void $ forkIO $ forever $ do + recordHasqlPoolStats pool + threadDelay (5 * 1_000_000) -- 5s + +-- | Creates a pool from postgres config params. +-- +-- 'acquisitionTimeout' is mapped to the pool acquisition timeout, +-- 'idlenessTimeout' controls how long idle connections stay resident. +initPostgresPool :: PoolConfig -> Map Text Text -> Maybe FilePathSecrets -> IO Pool +initPostgresPool config pgConfig mFpSecrets = do + mPw <- for mFpSecrets initCredentials + let pgSettings = + HasqlConnSettings.connectionString (PostgresqlConnectionString.toUrl $ PostgresqlConnectionString.fromKeyValueParams pgConfig) + <> foldMap HasqlConnSettings.password mPw + metrics <- mkHasqlPoolMetrics + rawPool <- + HasqlPool.acquireWith + (instrumentedConnectionGetter metrics (Hasql.Connection.acquire pgSettings)) + ( config.size, + realToFrac config.idlenessTimeout.duration, + unusedSettings + ) + let pool = Pool {rawPool, metrics, poolAcquisitionTimeout = config.acquisitionTimeout} + startHasqlPoolStatsReporter pool + pure pool + where + instrumentedConnectionGetter metrics getter = do + started <- getMonotonicTime + res <- getter + ended <- getMonotonicTime + recordHasqlPoolConnectionAcquisition metrics (ended - started) + case res of + Right _ -> recordHasqlPoolConnectionEstablished metrics + Left _ -> recordHasqlPoolConnectionFailure metrics + pure res + + mkHasqlPoolMetrics :: IO HasqlPoolMetrics + mkHasqlPoolMetrics = + HasqlPoolMetrics + <$> register (gauge $ Info "wire_hasql_pool_ready_for_use" "Number of hasql pool connections ready for use") + <*> register (gauge $ Info "wire_hasql_pool_in_use" "Number of hasql pool connections in use") + <*> register (counter $ Info "wire_hasql_pool_connection_established_count" "Number of established connections") + <*> register (counter $ Info "wire_hasql_pool_connection_failure_count" "Number of failed connection acquisition attempts") + <*> register (counter $ Info "wire_hasql_pool_acquisition_timeout_count" "Number of pool acquisition timeouts") + <*> register (counter $ Info "wire_hasql_pool_session_failure_count" "Number of times a session has failed") + <*> register (counter $ Info "wire_hasql_pool_session_count" "Number of times a session was created") + <*> register (histogram (Info "wire_hasql_pool_connection_acquisition_seconds" "Time spent establishing new PostgreSQL connections") defaultBuckets) + <*> register (histogram (Info "wire_hasql_pool_session_seconds" "Time spent using PostgreSQL sessions") defaultBuckets) + + unusedSettings = + -- The custom getter above performs the actual connection establishment. + -- The API forces us to pass this record, but it is actually not used in acquireWith + HasqlPool.ConnectionSettings + { host = "", + port = 5432, + user = "", + password = "", + dbName = "", + connAcqTimeout = 0, + txIdleTimeout = HasqlPool.TimeoutSetting 0 HasqlPool.Seconds, + stmtTimeout = HasqlPool.TimeoutSetting 0 HasqlPool.Seconds, + sslMode = "prefer", + sslRootCert = "" + } diff --git a/libs/types-common/src/Data/Misc.hs b/libs/types-common/src/Data/Misc.hs index 8f0129a62a9..63f949f4b2d 100644 --- a/libs/types-common/src/Data/Misc.hs +++ b/libs/types-common/src/Data/Misc.hs @@ -50,6 +50,7 @@ module Data.Misc mkDurationLiteral, unsafeDurationLiteral, durationToMicros, + durationToCeilingSeconds, -- * HttpsUrl HttpsUrl (..), @@ -330,6 +331,17 @@ durationToMicros :: Duration -> Int durationToMicros = fromInteger . flip div 1_000_000 . diffTimeToPicoseconds . duration +-- | Convert a 'Duration' to whole seconds, rounding up and clamping a bounded integral type. +-- Non-positive durations map to zero. +durationToCeilingSeconds :: forall a. (Bounded a, Integral a) => Duration -> a +durationToCeilingSeconds d + | duration d <= 0 = 0 + | otherwise = + fromInteger $ + min + (toInteger (maxBound :: a)) + (ceiling (duration d) :: Integer) + instance FromJSON Duration where parseJSON = withText "Duration" $ either fail pure . parseDuration diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix index 50b26ec421f..cbda44f339a 100644 --- a/libs/wire-subsystems/default.nix +++ b/libs/wire-subsystems/default.nix @@ -53,7 +53,7 @@ , HaskellNet-SSL , hasql , hasql-migration -, hasql-pool +, hasql-resource-pool , hasql-th , hasql-transaction , hex @@ -192,7 +192,7 @@ mkDerivation { HaskellNet-SSL hasql hasql-migration - hasql-pool + hasql-resource-pool hasql-th hasql-transaction hex @@ -322,7 +322,7 @@ mkDerivation { HaskellNet-SSL hasql hasql-migration - hasql-pool + hasql-resource-pool hasql-th hasql-transaction hex diff --git a/libs/wire-subsystems/src/Wire/AppStore/Postgres.hs b/libs/wire-subsystems/src/Wire/AppStore/Postgres.hs index 2f46d3ee28d..a7a37fdee80 100644 --- a/libs/wire-subsystems/src/Wire/AppStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/AppStore/Postgres.hs @@ -24,22 +24,16 @@ where import Data.Id import Data.Range -import Hasql.Pool import Hasql.TH import Imports import Polysemy -import Polysemy.Error (Error) -import Polysemy.Input import Wire.API.PostgresMarshall import Wire.API.User qualified as User import Wire.AppStore import Wire.Postgres interpretAppStoreToPostgres :: - ( Member (Embed IO) r, - Member (Input Pool) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => InterpreterFor AppStore r interpretAppStoreToPostgres = interpret $ \case @@ -50,10 +44,7 @@ interpretAppStoreToPostgres = DeleteApp userId teamId -> deleteAppImpl userId teamId createAppImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => StoredApp -> Sem r () createAppImpl app = @@ -64,10 +55,7 @@ createAppImpl app = values ($1 :: uuid, $2 :: uuid, $3 :: json, $4 :: text, $5 :: text, $6 :: uuid) |] getAppImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => UserId -> TeamId -> Sem r (Maybe StoredApp) @@ -85,10 +73,7 @@ eraseMetadata :: StoredApp -> StoredApp eraseMetadata sap = sap {meta = mempty} getAppsImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => TeamId -> Sem r [StoredApp] getAppsImpl tid = @@ -99,10 +84,7 @@ getAppsImpl tid = from apps where team_id = ($1 :: uuid) |] updateAppImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => TeamId -> UserId -> StoredAppUpdate -> @@ -137,10 +119,7 @@ updateAppImpl (toUUID -> teamId) (toUUID -> appId) upd = do pure $ maybe (Left NotFound) (\_ -> Right ()) found deleteAppImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => UserId -> TeamId -> Sem r () diff --git a/libs/wire-subsystems/src/Wire/CodeStore/Migration.hs b/libs/wire-subsystems/src/Wire/CodeStore/Migration.hs index adbdcfe68e0..e652c5619a3 100644 --- a/libs/wire-subsystems/src/Wire/CodeStore/Migration.hs +++ b/libs/wire-subsystems/src/Wire/CodeStore/Migration.hs @@ -28,7 +28,7 @@ import Data.Id (ConvId) import Data.Misc (HttpsUrl) import Data.Text qualified as T import Data.Time -import Hasql.Pool qualified as Hasql +import Hasql.Pool.Extended qualified as Hasql import Imports import Polysemy import Polysemy.Async diff --git a/libs/wire-subsystems/src/Wire/ConversationStore/Migration.hs b/libs/wire-subsystems/src/Wire/ConversationStore/Migration.hs index cdd72d0097c..8c5ddc11bd7 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore/Migration.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore/Migration.hs @@ -34,7 +34,8 @@ import Data.Time import Data.Time.Calendar.OrdinalDate (fromOrdinalDate) import Data.Vector (Vector) import Data.Vector qualified as Vector -import Hasql.Pool qualified as Hasql +import Hasql.Pool (UsageError) +import Hasql.Pool.Extended qualified as Hasql import Hasql.Statement qualified as Hasql import Hasql.TH import Hasql.Transaction qualified as Transaction @@ -196,7 +197,7 @@ migrateAllUsers migOpts migCounter migDuration = do select :: PrepQuery R () (Identity UserId) select = "select distinct user from user_remote_conv" -handleErrors :: (Member (State Int) r, Member TinyLog r) => (Id a -> Sem (Error MigrationLockError : Error Hasql.UsageError : r) b) -> ByteString -> Id a -> Sem r (Maybe b) +handleErrors :: (Member (State Int) r, Member TinyLog r) => (Id a -> Sem (Error MigrationLockError : Error UsageError : r) b) -> ByteString -> Id a -> Sem r (Maybe b) handleErrors action lockType id_ = join <$> handleError (handleError action lockType) lockType id_ diff --git a/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs b/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs index 067cb970071..9dee5441e8e 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs @@ -34,7 +34,6 @@ import Data.Vector qualified as Vector import GHC.Records (HasField) import Hasql.Decoders qualified as HD import Hasql.Pipeline qualified as Pipeline -import Hasql.Pool qualified as Hasql import Hasql.Session qualified as HasqlSession import Hasql.Statement qualified as Hasql import Hasql.TH @@ -43,8 +42,6 @@ import Hasql.Transaction qualified as Transaction import Hasql.Transaction.Sessions (IsolationLevel (ReadCommitted), Mode (..)) import Imports import Polysemy -import Polysemy.Error -import Polysemy.Input import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.CellsState import Wire.API.Conversation.Pagination @@ -1317,10 +1314,7 @@ rawResultToSearchResult r = do } searchConversationsImpl :: - ( Member (Input Hasql.Pool) r, - Member (Error Hasql.UsageError) r, - Member (Embed IO) r - ) => + (PGConstraints r) => ConversationSearch -> Sem r [ConversationSearchResult] searchConversationsImpl req = diff --git a/libs/wire-subsystems/src/Wire/DomainRegistrationStore/Migration.hs b/libs/wire-subsystems/src/Wire/DomainRegistrationStore/Migration.hs index 26a7d5de941..5047d4b61e2 100644 --- a/libs/wire-subsystems/src/Wire/DomainRegistrationStore/Migration.hs +++ b/libs/wire-subsystems/src/Wire/DomainRegistrationStore/Migration.hs @@ -27,7 +27,8 @@ import Data.Conduit.List qualified as C import Data.Domain import Data.Id import Database.CQL.Protocol (Record (asRecord), TupleType) -import Hasql.Pool qualified as Hasql +import Hasql.Pool (UsageError) +import Hasql.Pool.Extended qualified as Hasql import Imports hiding (lookup) import Polysemy import Polysemy.Async @@ -180,7 +181,7 @@ handleRegistrationErrors :: Member TinyLog r ) => ByteString -> - (Sem (Error MigrationLockError : Error Hasql.UsageError : r) ()) -> + (Sem (Error MigrationLockError : Error UsageError : r) ()) -> Sem r () handleRegistrationErrors key action = do eithErr <- runError (runError action) diff --git a/libs/wire-subsystems/src/Wire/Error.hs b/libs/wire-subsystems/src/Wire/Error.hs index b526bb6c9da..cab57eb1887 100644 --- a/libs/wire-subsystems/src/Wire/Error.hs +++ b/libs/wire-subsystems/src/Wire/Error.hs @@ -35,7 +35,6 @@ import Network.HTTP.Types import Network.Wai import Network.Wai.Utilities import Network.Wai.Utilities.Error qualified as Wai -import Network.Wai.Utilities.Exception import Network.Wai.Utilities.JSONResponse import Network.Wai.Utilities.Server import Servant (ServerError (..)) @@ -82,14 +81,14 @@ httpErrorToJSONResponse e@(RichError werr _ headers) = postgresUsageErrorToHttpError :: UsageError -> HttpError postgresUsageErrorToHttpError err = case err of - SessionUsageError _se -> + SessionError _se -> -- FUTUREWORK: should this case should be more nuanced? eg., if a foreign key is dangling, should we -- return "404 not found", not "database crashed"? -- The problem is that the SessionError is not typed to easily be parsed -- To prevent foreign key errors we should check the foreign key constraints before inserting - StdError (Wai.mkError status500 "server-error" (LT.pack $ "postgres: " <> displayExceptionNoBacktrace err)) - ConnectionUsageError _ -> StdError (Wai.mkError status500 "server-error" (LT.pack $ "postgres: " <> displayExceptionNoBacktrace err)) - AcquisitionTimeoutUsageError -> StdError (Wai.mkError status500 "server-error" (LT.pack $ "postgres: " <> displayExceptionNoBacktrace err)) + StdError (Wai.mkError status500 "server-error" (LT.pack $ "postgres: " <> show err)) + ConnectionError _ -> StdError (Wai.mkError status500 "server-error" (LT.pack $ "postgres: " <> show err)) + AcquisitionTimeoutUsageError -> StdError (Wai.mkError status500 "server-error" (LT.pack $ "postgres: " <> show err)) -- | Extract the wai error from an HttpError and convert into a -- servant error. `RichError` extra data is discarded! diff --git a/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs index 30bfd2fb298..a4bb9ddec74 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs @@ -30,14 +30,11 @@ import Data.Range (Range, fromRange) import Data.Time.Clock import Data.UUID (UUID, nil) import Data.Vector qualified as V -import Hasql.Pool import Hasql.Session import Hasql.Statement import Hasql.TH import Imports import Polysemy -import Polysemy.Error (Error, throw) -import Polysemy.Input import Wire.API.Meeting (Recurrence) import Wire.API.PostgresMarshall (PostgresMarshall (..), PostgresUnmarshall (..), dimapPG) import Wire.API.User.Identity (EmailAddress, fromEmail) @@ -245,10 +242,7 @@ updateMeetingImpl meetingId mTitle mStartDate mEndDate mRecurrence = do -- * Delete deleteMeetingImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => MeetingId -> Sem r () deleteMeetingImpl meetingId = do @@ -403,17 +397,12 @@ replaceInvitedEmailsImpl meetingId emails = do |] getOldMeetingsImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => UTCTime -> Int -> Sem r [StoredMeeting] getOldMeetingsImpl cutoffTime batchSize = do - pool <- input - result <- liftIO $ use pool session - either throw pure result + runSession session where session :: Session [StoredMeeting] session = statement (cutoffTime, fromIntegral batchSize) $ V.toList <$> listStatement diff --git a/libs/wire-subsystems/src/Wire/MigrationLock.hs b/libs/wire-subsystems/src/Wire/MigrationLock.hs index 0c9eedf1260..3fd75598280 100644 --- a/libs/wire-subsystems/src/Wire/MigrationLock.hs +++ b/libs/wire-subsystems/src/Wire/MigrationLock.hs @@ -25,6 +25,7 @@ import Data.Id import Data.UUID qualified as UUID import Data.Vector (Vector) import Hasql.Pool qualified as Hasql +import Hasql.Pool.Extended qualified as HasqlPoolExt import Hasql.Session qualified as Session import Hasql.Statement qualified as Hasql import Hasql.TH @@ -97,7 +98,7 @@ withMigrationLocks lockType maxWait lockables action = do lockAcquired <- embed newEmptyMVar actionCompleted <- embed newEmptyMVar - pool <- input + pool <- (.rawPool) <$> input @HasqlPoolExt.Pool lockThread <- async . embed . Hasql.use pool $ do let lockIds = fmap lockKey lockables Session.statement lockIds acquireLocks diff --git a/libs/wire-subsystems/src/Wire/Postgres.hs b/libs/wire-subsystems/src/Wire/Postgres.hs index d18de5f10a8..f91f3637efe 100644 --- a/libs/wire-subsystems/src/Wire/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/Postgres.hs @@ -70,15 +70,17 @@ where import Control.Monad.Trans.State import Data.Functor.Contravariant import Data.Id +import Data.Misc import Data.Text qualified as Text import Data.Text.Encoding qualified as Text -import Data.Time.Clock +import Data.Time.Clock (UTCTime) import Hasql.Decoders qualified as Dec import Hasql.Encoders qualified as Enc import Hasql.Errors import Hasql.Pipeline (Pipeline) -import Hasql.Pool import Hasql.Pool qualified as Hasql +import Hasql.Pool.Extended qualified as HasqlPoolExt +import Hasql.Pool.Observer qualified as HasqlObserver import Hasql.Session import Hasql.Statement import Hasql.Transaction (Transaction) @@ -92,7 +94,7 @@ import PostgreSQL.ErrorCodes qualified as PostgreSQL import Wire.API.Pagination type PGConstraints r = - ( Member (Input Hasql.Pool) r, + ( Member (Input HasqlPoolExt.Pool) r, Member (Embed IO) r, Member (Error Hasql.UsageError) r ) @@ -100,8 +102,10 @@ type PGConstraints r = -- | Resets the pool if it detects server errors due to admin intervention. -- Things like server restart. Then retries the session. -- --- Inspired by https://github.com/nikita-volkov/hasql-pool/issues/27 -useWithResetAndRetry :: forall a. Pool -> Session a -> IO (Either UsageError a) +-- Inspired by https://github.com/nikita-volkov/hasql-pool/issues/27. +-- The old issue still describes the server-error retry pattern, even though +-- this module now uses hasql-resource-pool. +useWithResetAndRetry :: forall a. HasqlPoolExt.Pool -> Session a -> IO (Either Hasql.UsageError a) useWithResetAndRetry pool sess = go maxRetries where maxRetries :: Int @@ -110,19 +114,35 @@ useWithResetAndRetry pool sess = go maxRetries resettableErrors :: [ByteString] resettableErrors = [PostgreSQL.admin_shutdown, PostgreSQL.crash_shutdown, PostgreSQL.cannot_connect_now, PostgreSQL.database_dropped] - go :: Int -> IO (Either UsageError a) - go 0 = use pool sess + go :: Int -> IO (Either Hasql.UsageError a) + go 0 = useObservedNoRetry pool sess go n = do - eithRes <- use pool sess + eithRes <- useObservedNoRetry pool sess case eithRes of - Left (SessionUsageError (StatementSessionError _ _ _ _ _ (ServerStatementError (ServerError errCode _ _ _ _)))) -> do + Left (Hasql.SessionError (StatementSessionError _ _ _ _ _ (ServerStatementError (ServerError errCode _ _ _ _)))) -> do if (Text.encodeUtf8 errCode `elem` resettableErrors) then do - release pool + Hasql.release pool.rawPool go (n - 1) else pure eithRes _ -> pure eithRes + useObservedNoRetry :: HasqlPoolExt.Pool -> Session a -> IO (Either Hasql.UsageError a) + useObservedNoRetry p s = do + HasqlPoolExt.recordHasqlPoolSessionStarted p.metrics + result <- + Hasql.useWithObserverAndPoolAcquisitionTimeout + (Just \observed -> HasqlPoolExt.recordHasqlPoolSessionDuration p.metrics (realToFrac $ HasqlObserver.latency observed)) + (durationToCeilingSeconds p.poolAcquisitionTimeout) + p.rawPool + s + case result of + Left (Hasql.ConnectionError _) -> HasqlPoolExt.recordHasqlPoolConnectionFailure p.metrics + Left (Hasql.SessionError _) -> HasqlPoolExt.recordHasqlPoolSessionFailure p.metrics + Left Hasql.AcquisitionTimeoutUsageError -> HasqlPoolExt.recordHasqlPoolAcquisitionTimeout p.metrics + Right _ -> pure () + pure result + -- | Runs a 'Session' using the 'Hasql.Pool'. Retries on server errors due to -- admin intervention. Things like server restart. runSessionWithRetry :: @@ -142,7 +162,23 @@ runSessionWithRetry sess = do runSession :: (PGConstraints r) => Session a -> Sem r a runSession sess = do pool <- input - liftIO (use pool sess) >>= either throw pure + liftIO (useObserved pool sess) >>= either throw pure + +useObserved :: HasqlPoolExt.Pool -> Session a -> IO (Either Hasql.UsageError a) +useObserved pool sess = do + HasqlPoolExt.recordHasqlPoolSessionStarted pool.metrics + result <- + Hasql.useWithObserverAndPoolAcquisitionTimeout + (Just \observed -> HasqlPoolExt.recordHasqlPoolSessionDuration pool.metrics (realToFrac $ HasqlObserver.latency observed)) + (durationToCeilingSeconds pool.poolAcquisitionTimeout) + pool.rawPool + sess + case result of + Left (Hasql.ConnectionError _) -> HasqlPoolExt.recordHasqlPoolConnectionFailure pool.metrics + Left (Hasql.SessionError _) -> HasqlPoolExt.recordHasqlPoolSessionFailure pool.metrics + Left Hasql.AcquisitionTimeoutUsageError -> HasqlPoolExt.recordHasqlPoolAcquisitionTimeout pool.metrics + Right _ -> pure () + pure result -- | Runs a 'Statement' using the 'Hasql.Pool'. Always retries on server errors -- due to admin intervention. Things like server restart. diff --git a/libs/wire-subsystems/src/Wire/PostgresMigrations.hs b/libs/wire-subsystems/src/Wire/PostgresMigrations.hs index d8b022b1ffd..f3372235d28 100644 --- a/libs/wire-subsystems/src/Wire/PostgresMigrations.hs +++ b/libs/wire-subsystems/src/Wire/PostgresMigrations.hs @@ -44,7 +44,9 @@ allMigrations = map (\(name, contentBS) -> MigrationScript name (Text.decodeUtf8 nonTransactionMigrations :: Set ScriptName nonTransactionMigrations = Set.fromList ["20260428072649-create-conv-parent-index.sql"] -data PostgresMigrationError = PostgresMigrationError MigrationError +data PostgresMigrationError + = PostgresMigrationError MigrationError + | PostgresMigrationUsageError UsageError deriving (Show) instance Exception PostgresMigrationError @@ -71,7 +73,7 @@ runAllMigrations pool logger = do Just err -> throw $ PostgresMigrationError err Log.info logger $ Log.msg (Log.val "Migrations completed successfully") - either throwIO pure =<< use pool session + either (throwIO . PostgresMigrationUsageError) pure =<< use pool session where -- We must use `try` instead of blocking on the lock because running `CREATE -- INDEX CONCURRENTLY` requires all transactions to be complete and blocking @@ -120,4 +122,4 @@ resetSchema pool logger = do let session = do script "DROP SCHEMA IF EXISTS public CASCADE" script "CREATE SCHEMA IF NOT EXISTS public" - either throwIO pure =<< use pool session + either (throwIO . PostgresMigrationUsageError) pure =<< use pool session diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs index b454f1380d1..a6a1e968a72 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs @@ -29,21 +29,17 @@ import Data.Set import Data.Set qualified as Set import Data.UUID import Data.Vector hiding (mapM) -import Hasql.Pool import Hasql.Statement import Hasql.TH import Imports import Polysemy import Polysemy.Error (Error, throw) -import Polysemy.Input import Wire.API.Team.Collaborator import Wire.Postgres import Wire.TeamCollaboratorsStore interpretTeamCollaboratorsStoreToPostgres :: - ( Member (Embed IO) r, - Member (Input Pool) r, - Member (Error UsageError) r, + ( PGConstraints r, Member (Error TeamCollaboratorsError) r ) => InterpreterFor TeamCollaboratorsStore r @@ -101,10 +97,7 @@ createTeamCollaboratorImpl userId teamId permissions = do |] getAllTeamCollaboratorsImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => TeamId -> Sem r [TeamCollaborator] getAllTeamCollaboratorsImpl teamId = do @@ -118,10 +111,7 @@ getAllTeamCollaboratorsImpl teamId = do |] updateTeamCollaboratorImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => UserId -> TeamId -> Set CollaboratorPermission -> @@ -140,10 +130,7 @@ updateTeamCollaboratorImpl userId teamId permissions = do |] removeTeamCollaboratorImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => UserId -> TeamId -> Sem r () @@ -181,10 +168,7 @@ postgreslRepToCollaboratorPermission = (collaboratorPermissionMap Bimap.! {- `!` throws if the element isn't found -}) getTeamCollaborationsImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => UserId -> Sem r [TeamCollaborator] getTeamCollaborationsImpl teamId = do @@ -198,10 +182,7 @@ getTeamCollaborationsImpl teamId = do |] getTeamCollaboratorsWithIdsImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => Set TeamId -> Set UserId -> Sem r [TeamCollaborator] diff --git a/libs/wire-subsystems/src/Wire/TeamFeatureStore/Migration.hs b/libs/wire-subsystems/src/Wire/TeamFeatureStore/Migration.hs index 060fd86b38a..1edeb7daf2c 100644 --- a/libs/wire-subsystems/src/Wire/TeamFeatureStore/Migration.hs +++ b/libs/wire-subsystems/src/Wire/TeamFeatureStore/Migration.hs @@ -22,7 +22,8 @@ import Data.ByteString.Conversion import Data.Conduit import Data.Conduit.List qualified as C import Data.Id -import Hasql.Pool qualified as Hasql +import Hasql.Pool (UsageError) +import Hasql.Pool.Extended qualified as Hasql import Imports import Polysemy import Polysemy.Async @@ -137,7 +138,7 @@ handleErrors :: Member TinyLog r ) => ByteString -> - (Sem (Error MigrationLockError : Error Hasql.UsageError : r) ()) -> + (Sem (Error MigrationLockError : Error UsageError : r) ()) -> Sem r () handleErrors key action = do eithErr <- runError (runError action) diff --git a/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs index 6702f5a41f4..bc034c009c4 100644 --- a/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs @@ -32,7 +32,6 @@ import Data.UUID as UUID import Data.Vector (Vector) import Data.Vector qualified as V import Hasql.Decoders qualified as HD -import Hasql.Pool import Hasql.Session import Hasql.Statement import Hasql.TH @@ -40,7 +39,6 @@ import Hasql.Transaction qualified as Tx import Hasql.Transaction.Sessions qualified as TxSessions import Imports import Polysemy -import Polysemy.Error (Error) import Polysemy.Input import Wire.API.Pagination import Wire.API.PostgresMarshall @@ -52,10 +50,7 @@ import Wire.Postgres import Wire.UserGroupStore (UserGroupStore (..)) type UserGroupStorePostgresEffectConstraints r = - ( Member (Embed IO) r, - Member (Input Pool) r, - Member (Error UsageError) r - ) + PGConstraints r interpretUserGroupStoreToPostgres :: forall r. diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 680a8e71519..9ee955bcaf3 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -129,7 +129,7 @@ common common-all , HaskellNet-SSL , hasql , hasql-migration - , hasql-pool + , hasql-resource-pool , hasql-th , hasql-transaction , hex diff --git a/nix/haskell-pins.nix b/nix/haskell-pins.nix index 40cd37a0110..17849cbc878 100644 --- a/nix/haskell-pins.nix +++ b/nix/haskell-pins.nix @@ -168,6 +168,12 @@ let src = inputs.postgresql-connection-string; }; + # Wire fork with pool acquisition timeout support. + # Update this to the upstream repo/rev once the PR is merged there. + hasql-resource-pool = { + src = inputs.hasql-resource-pool; + }; + cryptostore = { src = inputs.cryptostore; }; @@ -198,6 +204,7 @@ let version = "0.13"; sha256 = "sha256-m8Q1jwCyDrlEPbv2cZ/FIv/ey3dPjDVkmppzvi3Zjw4="; }; + }; # Name -> Source -> Maybe Subpath -> Drv mkGitDrv = name: src: subpath: diff --git a/nix/manual-overrides.nix b/nix/manual-overrides.nix index b176136ead1..59373e631c3 100644 --- a/nix/manual-overrides.nix +++ b/nix/manual-overrides.nix @@ -26,7 +26,9 @@ hself: hsuper: { # Tests require a running postgresql hasql = hlib.dontCheck hsuper.hasql_1_10_3; - hasql-pool = hlib.dontCheck hsuper.hasql-pool_1_4_2; + # The library builds with hasql-1.10.x, but its packaged test suite still + # uses older hasql APIs. + hasql-resource-pool = hlib.dontCheck hsuper.hasql-resource-pool; hasql-migration = hlib.markUnbroken (hlib.doJailbreak (hlib.dontCheck hsuper.hasql-migration)); hasql-transaction = hlib.dontCheck hsuper.hasql-transaction_1_2_2; postgresql-binary = hlib.dontCheck (hsuper.postgresql-binary_0_15_0_1); diff --git a/services/background-worker/background-worker.cabal b/services/background-worker/background-worker.cabal index a3943555eb7..af6b66a8ffe 100644 --- a/services/background-worker/background-worker.cabal +++ b/services/background-worker/background-worker.cabal @@ -47,7 +47,7 @@ library , extended , extra , galley-types - , hasql-pool + , hasql-resource-pool , HsOpenSSL , http-client , http2-manager diff --git a/services/background-worker/background-worker.integration.yaml b/services/background-worker/background-worker.integration.yaml index d794fbb0cc5..97734fccd3b 100644 --- a/services/background-worker/background-worker.integration.yaml +++ b/services/background-worker/background-worker.integration.yaml @@ -33,7 +33,6 @@ cassandraBrig: postgresqlPool: size: 5 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m rabbitmq: diff --git a/services/background-worker/default.nix b/services/background-worker/default.nix index 1b08c073784..28faab430e9 100644 --- a/services/background-worker/default.nix +++ b/services/background-worker/default.nix @@ -19,7 +19,7 @@ , extra , federator , galley-types -, hasql-pool +, hasql-resource-pool , HsOpenSSL , hspec , http-client @@ -78,7 +78,7 @@ mkDerivation { extended extra galley-types - hasql-pool + hasql-resource-pool HsOpenSSL http-client http2-manager diff --git a/services/background-worker/src/Wire/BackgroundWorker/Env.hs b/services/background-worker/src/Wire/BackgroundWorker/Env.hs index 84cbbfd73ab..2bccadee873 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Env.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Env.hs @@ -30,8 +30,8 @@ import Data.Id (TeamId) import Data.Map.Strict qualified as Map import Data.Misc (HttpsUrl) import HTTP2.Client.Manager -import Hasql.Pool qualified as Hasql import Hasql.Pool.Extended +import Hasql.Pool.Extended qualified as Hasql import Imports import Network.AMQP qualified as Q import Network.AMQP.Extended diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index e20946dee40..57330f9f1e3 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -36,7 +36,7 @@ import Data.Text qualified as T import Data.Text.Lazy qualified as TL import Galley.Types.Error (InternalError, internalErrorDescription, legalHoldServiceUnavailable) import Hasql.Pool (UsageError) -import Hasql.Pool qualified as Hasql +import Hasql.Pool.Extended qualified as HasqlPoolExt import Imports import Network.HTTP.Client qualified as Http import Network.Wai.Utilities.JSONResponse (JSONResponse (..)) @@ -229,7 +229,7 @@ type BackgroundWorkerEffects = Input ClientState, Input (FeatureDefaults LegalholdConfig), Input (Local ()), - Input Hasql.Pool, + Input HasqlPoolExt.Pool, P.TinyLog, Error RateLimitExceeded, Error UnreachableBackendsLegacy, @@ -302,7 +302,7 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . mapError @UnreachableBackendsLegacy (const ("Unreachable backends legacy" :: Text)) . mapError @RateLimitExceeded (const ("Rate limit exceeded" :: Text)) . interpretTinyLog - . runInputConst @Hasql.Pool env.hasqlPool + . runInputConst @HasqlPoolExt.Pool env.hasqlPool . runInputConst @(Local ()) (toLocalUnsafe env.federationDomain ()) . runInputConst @(FeatureDefaults LegalholdConfig) (env.conversationSubsystemConfig.legalholdDefaults) . runInputConst @ClientState env.cassandraGalley diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index 2f7ab0b0789..7868d06447c 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -245,7 +245,7 @@ library , fsnotify >=0.4 , galley-types >=0.75.3 , hashable >=1.2 - , hasql-pool + , hasql-resource-pool , hs-opentelemetry-instrumentation-wai , hs-opentelemetry-sdk , HsOpenSSL >=0.10 diff --git a/services/brig/brig.integration.yaml b/services/brig/brig.integration.yaml index 05ec68a0213..8be11f028bd 100644 --- a/services/brig/brig.integration.yaml +++ b/services/brig/brig.integration.yaml @@ -29,7 +29,6 @@ postgresql: postgresqlPool: size: 20 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m rabbitmq: diff --git a/services/brig/default.nix b/services/brig/default.nix index 4ccc141c029..e431f9c93db 100644 --- a/services/brig/default.nix +++ b/services/brig/default.nix @@ -47,7 +47,7 @@ , fsnotify , galley-types , hashable -, hasql-pool +, hasql-resource-pool , hs-opentelemetry-instrumentation-wai , hs-opentelemetry-sdk , hscim @@ -192,7 +192,7 @@ mkDerivation { fsnotify galley-types hashable - hasql-pool + hasql-resource-pool hs-opentelemetry-instrumentation-wai hs-opentelemetry-sdk HsOpenSSL diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index 1e50c5179ed..9a4a59dda72 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -137,8 +137,8 @@ import Data.Text.IO qualified as Text import Data.Time.Clock import Database.Bloodhound qualified as ES import HTTP2.Client.Manager (Http2Manager, http2ManagerWithSSLCtx) -import Hasql.Pool qualified as HasqlPool import Hasql.Pool.Extended +import Hasql.Pool.Extended qualified as HasqlPool import Imports import Network.AMQP qualified as Q import Network.AMQP.Extended qualified as Q diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index afa2ecc20e3..358aa19ea7a 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -40,7 +40,7 @@ import Data.Coerce (coerce) import Data.Qualified (Local, toLocalUnsafe) import Data.ZAuth.CryptoSign (CryptoSign, runCryptoSign) import Hasql.Pool (UsageError) -import Hasql.Pool qualified as Hasql +import Hasql.Pool.Extended qualified as HasqlPoolExt import Imports import Network.Wai.Utilities.Error qualified as Wai import Polysemy @@ -242,7 +242,7 @@ type BrigLowerLevelEffects = SFT, ConnectionStore InternalPaging, Input Cas.ClientState, - Input Hasql.Pool, + Input HasqlPoolExt.Pool, Input AppSubsystemConfig, Input UserSubsystemConfig, Input VerificationCodeThrottleTTL, diff --git a/services/brig/src/Brig/Index/Eval.hs b/services/brig/src/Brig/Index/Eval.hs index f3b8f1c83b4..ea72f9aeef5 100644 --- a/services/brig/src/Brig/Index/Eval.hs +++ b/services/brig/src/Brig/Index/Eval.hs @@ -39,9 +39,9 @@ import Data.Credentials (Credentials (..)) import Data.Id import Database.Bloodhound qualified as ES import Database.Bloodhound.Internal.Client (BHEnv (..)) -import Hasql.Pool -import Hasql.Pool qualified as Hasql +import Hasql.Pool (UsageError) import Hasql.Pool.Extended +import Hasql.Pool.Extended qualified as Hasql import Imports import Network.HTTP.Client (Manager) import Polysemy @@ -93,6 +93,11 @@ type BrigIndexEffectStack = type SemDeps = (Manager, ClientState, Hasql.Pool, BHEnv, IndexedUserStoreConfig, RequestId, IndexName) +newtype PostgresUsageException = PostgresUsageException UsageError + deriving (Show) + +instance Exception PostgresUsageException + mkSemDeps :: ESConnectionSettings -> CassandraSettings -> PostgresSettings -> Logger -> IO SemDeps mkSemDeps esConn cas pg logger = do mgr <- initHttpManagerWithTLSConfig esConn.esInsecureSkipVerifyTls esConn.esCaCert @@ -127,7 +132,7 @@ runSem (mgr, casClient, pgPool, bhEnv, indexedUserStoreConfig, reqId, migrationI runFinal . embedToFinal . throwErrorToIOFinal @ClientError - . throwErrorToIOFinal @UsageError + . throwPostgresUsageErrorToIOFinal . runInputConst pgPool . loggerToTinyLogReqId reqId logger . ignoreMetrics @@ -148,6 +153,12 @@ throwErrorToIOFinal action = do Left e -> embedFinal $ throwIO e Right a -> pure a +throwPostgresUsageErrorToIOFinal :: (Member (Final IO) r) => InterpreterFor (Error UsageError) r +throwPostgresUsageErrorToIOFinal action = do + runError action >>= \case + Left e -> embedFinal $ throwIO (PostgresUsageException e) + Right a -> pure a + runCommand :: Logger -> Command -> IO () runCommand l = \case Create es galley -> do diff --git a/services/brig/src/Brig/Index/Options.hs b/services/brig/src/Brig/Index/Options.hs index bbf0c9880ed..b19c1f3124f 100644 --- a/services/brig/src/Brig/Index/Options.hs +++ b/services/brig/src/Brig/Index/Options.hs @@ -367,13 +367,6 @@ poolConfigParser = <> help "Pool acquisition timeout in seconds" <> value (unsafeParseDuration "10s") ) - <*> option - (eitherReader (parseDuration . Text.pack)) - ( long "pg-pool-aging-timeout" - <> metavar "Duration" - <> help "Pool aging timeout in seconds" - <> value (unsafeParseDuration "1d") - ) <*> option (eitherReader (parseDuration . Text.pack)) ( long "pg-pool-idleness-timeout" diff --git a/services/brig/src/Brig/Run.hs b/services/brig/src/Brig/Run.hs index 5b5d0f96818..6ef4967bcde 100644 --- a/services/brig/src/Brig/Run.hs +++ b/services/brig/src/Brig/Run.hs @@ -45,6 +45,7 @@ import Data.Metrics.AWS (gaugeTokenRemaing) import Data.Metrics.Servant qualified as Metrics import Data.Proxy (Proxy (Proxy)) import Data.Text (unpack) +import Hasql.Pool.Extended (rawPool) import Imports hiding (head) import Network.HTTP.Media qualified as HTTPMedia import Network.HTTP.Types qualified as HTTP @@ -82,7 +83,7 @@ import Wire.UserStore run :: Opts -> IO () run opts = withTracer \tracer -> do (app, e) <- mkApp opts - runAllMigrations e.hasqlPool e.appLogger + runAllMigrations e.hasqlPool.rawPool e.appLogger let s = Server.newSettings (server e) internalEventListener <- Async.async $ @@ -113,7 +114,7 @@ run opts = withTracer \tracer -> do migratePostgres :: Opts -> Bool -> IO () migratePostgres opts resetFirst = do logger <- initLogger opts - pool <- initPostgresPool opts.postgresqlPool opts.postgresql opts.postgresqlPassword + pool <- (.rawPool) <$> initPostgresPool opts.postgresqlPool opts.postgresql opts.postgresqlPassword when resetFirst $ resetSchema pool logger runAllMigrations pool logger flush logger diff --git a/services/galley/default.nix b/services/galley/default.nix index 8ab0f4c24c8..3f9eb5cb466 100644 --- a/services/galley/default.nix +++ b/services/galley/default.nix @@ -32,7 +32,7 @@ , federator , filepath , galley-types -, hasql-pool +, hasql-resource-pool , hs-opentelemetry-instrumentation-wai , hs-opentelemetry-sdk , HsOpenSSL @@ -134,7 +134,7 @@ mkDerivation { exceptions extended galley-types - hasql-pool + hasql-resource-pool hs-opentelemetry-instrumentation-wai hs-opentelemetry-sdk HsOpenSSL diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index 2ecfe746462..5ca4fdc34c8 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -210,7 +210,7 @@ library , exceptions >=0.4 , extended , galley-types >=0.65.0 - , hasql-pool + , hasql-resource-pool , hs-opentelemetry-instrumentation-wai , hs-opentelemetry-sdk , HsOpenSSL >=0.11 diff --git a/services/galley/galley.integration.yaml b/services/galley/galley.integration.yaml index 8703c55801c..55ef99cb81c 100644 --- a/services/galley/galley.integration.yaml +++ b/services/galley/galley.integration.yaml @@ -19,7 +19,6 @@ postgresql: postgresqlPool: size: 20 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m brig: diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index 1ac4b1d27b9..78183816268 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -63,6 +63,7 @@ import Galley.Types.Error import HTTP2.Client.Manager (Http2Manager, http2ManagerWithSSLCtx) import Hasql.Pool qualified as Hasql import Hasql.Pool.Extended (initPostgresPool) +import Hasql.Pool.Extended qualified as HasqlPoolExt import Imports hiding (forkIO) import Network.AMQP.Extended (mkRabbitMqChannelMVar) import Network.HTTP.Client (responseTimeoutMicro) @@ -267,7 +268,7 @@ type GalleyEffects = ErrorS 'InvalidOperation, Error RpcException, Input ClientState, - Input Hasql.Pool, + Input HasqlPoolExt.Pool, Input Env, Input ConversationSubsystemConfig, Error MigrationLockError, diff --git a/services/galley/src/Galley/Env.hs b/services/galley/src/Galley/Env.hs index 606a2807a30..24da89559cd 100644 --- a/services/galley/src/Galley/Env.hs +++ b/services/galley/src/Galley/Env.hs @@ -51,7 +51,7 @@ import Data.Misc (HttpsUrl) import Data.Time.Clock.DiffTime (millisecondsToDiffTime) import Galley.Queue qualified as Q import HTTP2.Client.Manager (Http2Manager) -import Hasql.Pool +import Hasql.Pool.Extended import Imports import Network.AMQP qualified as Q import Network.HTTP.Client diff --git a/services/galley/src/Galley/Run.hs b/services/galley/src/Galley/Run.hs index be55ed36d83..9ea6b3870ae 100644 --- a/services/galley/src/Galley/Run.hs +++ b/services/galley/src/Galley/Run.hs @@ -45,6 +45,7 @@ import Galley.Cassandra import Galley.Env import Galley.Monad import Galley.Queue qualified as Q +import Hasql.Pool.Extended (rawPool) import Imports import Network.HTTP.Media.RenderHeader qualified as HTTPMedia import Network.HTTP.Types qualified as HTTP @@ -74,7 +75,7 @@ run :: Opts -> IO () run opts = lowerCodensity do tracer <- withTracerC (app, env) <- mkApp opts - lift $ runAllMigrations env._hasqlPool env._applog + lift $ runAllMigrations env._hasqlPool.rawPool env._applog let settings' = newSettings $ defaultServer From 5b7f1cc0b6798cfbbfc5e215732a737549c1d20e Mon Sep 17 00:00:00 2001 From: Sven Tennie Date: Fri, 10 Jul 2026 15:41:15 +0200 Subject: [PATCH 007/113] Stabilize testSparScimCreateGetSearchUserGroup (#5331) The order of members in a SCIM group doesn't matter and exists only accidentally; because JSON has no notion of sets. So, ordering members' list entries by their `Ord` instance leads to stable comparisons. --- integration/test/Test/Spar.hs | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index 1210b18a8a9..26cd2f43207 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -37,6 +37,7 @@ import Data.String.Conversions (cs) import qualified Data.Text as ST import qualified Data.UUID as UUID import Data.UUID.V4 (nextRandom) +import qualified Data.Vector as V import qualified SAML2.WebSSO as SAML import qualified SAML2.WebSSO.Test.MockResponse as SAML import qualified SAML2.WebSSO.Test.Util as SAML @@ -467,16 +468,23 @@ testSparScimCreateGetSearchUserGroup = do -- Test getting a single SCIM group by id gid <- respGroup1.json %. "id" & asString gottenGroup1 <- getScimUserGroup OwnDomain tok gid - respGroup1.json `shouldMatch` gottenGroup1.json + do + expected <- normalizeValue respGroup1.json + actual <- normalizeValue gottenGroup1.json + expected `shouldMatch` actual -- Test filter (get in bulk) SCIM groups -- 1. Match "group", results in finding all three groups created above. - filterScimUserGroup OwnDomain tok (Just "displayName co \"group\"") `bindResponse` \allThreeResp -> - (allThreeResp.json %. "Resources" & asList) `shouldMatchSet` [createdGroup1, createdGroup2, createdGroup3] + filterScimUserGroup OwnDomain tok (Just "displayName co \"group\"") `bindResponse` \allThreeResp -> do + resources <- (allThreeResp.json %. "Resources" & asList) >>= mapM normalizeValue + expected <- mapM normalizeValue [createdGroup1, createdGroup2, createdGroup3] + resources `shouldMatchSet` expected -- 2. Match "another group", results in finding "another group" and "yet another group". - filterScimUserGroup OwnDomain tok (Just "displayName co \"another group\"") `bindResponse` \justTwo -> - (justTwo.json %. "Resources" & asList) `shouldMatchSet` [createdGroup2, createdGroup3] + filterScimUserGroup OwnDomain tok (Just "displayName co \"another group\"") `bindResponse` \justTwo -> do + resources <- (justTwo.json %. "Resources" & asList) >>= mapM normalizeValue + expected <- mapM normalizeValue [createdGroup2, createdGroup3] + resources `shouldMatchSet` expected -- 3. Empty groups should have empty member list. respGroup4 <- createScimUserGroup OwnDomain tok $ mkScimGroup "empty group" [] @@ -623,6 +631,20 @@ testSparScimUpdateUserGroup = do memberValues <- (resp.json %. "members") >>= asListOf (\m -> m %. "value" >>= asString) memberValues `shouldMatchSet` [charlieId, dianaId] +-- | Normalize on `Value` level +-- +-- Recursively sorts all JSON arrays. This produces a canonical form, where the +-- initial order of elements doesn't matter. Only use this function when that's +-- desired (i.e. there is no value in the order of elements). +normalizeValue :: (MakesValue a) => a -> App A.Value +normalizeValue a = normalizeValueArrays <$> make a + where + normalizeValueArrays :: A.Value -> A.Value + normalizeValueArrays (A.Object o) = A.Object (normalizeValueArrays <$> o) + normalizeValueArrays (A.Array arr) = + A.Array . V.fromList . sort $ V.toList (normalizeValueArrays <$> arr) + normalizeValueArrays v = v + testSparScimUpdateUserGroupRejectsInvalidMembers :: (HasCallStack) => App () testSparScimUpdateUserGroupRejectsInvalidMembers = do (alice, tid1, []) <- createTeam OwnDomain 1 From 81dc949ffa2932dbde66e6aa0222aaf0d5ed8c6c Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Fri, 10 Jul 2026 17:43:05 +0200 Subject: [PATCH 008/113] WPB-23896: address SonarQube findings (#5332) --- .github/workflows/build.yaml | 2 +- charts/backoffice/templates/deployment.yaml | 1 + charts/calling-test/templates/deployment.yaml | 1 + charts/demo-smtp/templates/deployment.yaml | 1 + charts/elasticsearch-ephemeral/templates/es.yaml | 1 + charts/fake-aws-ses/templates/deployment.yaml | 1 + charts/fake-aws-sns/templates/deployment.yaml | 1 + charts/fake-aws-sqs/templates/deployment.yaml | 1 + charts/openldap/templates/openldap.yaml | 1 + charts/outlook-addin/templates/deployment.yaml | 1 + hack/python/wire/frozendict.py | 4 ++++ 11 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a8e609c436a..ca322ab6951 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Nix - uses: cachix/install-nix-action@v16 + uses: cachix/install-nix-action@d56f3ce9be45c562799280e8a561fbbe8f36de44 # v16 - name: Clone wire-docs and build run: | diff --git a/charts/backoffice/templates/deployment.yaml b/charts/backoffice/templates/deployment.yaml index 19eba226347..fa75cb7f486 100644 --- a/charts/backoffice/templates/deployment.yaml +++ b/charts/backoffice/templates/deployment.yaml @@ -26,6 +26,7 @@ spec: # An annotation of the configmap checksum ensures changes to the configmap cause a redeployment upon `helm upgrade` checksum/configmap: {{ include (print .Template.BasePath "/configmap.yaml") . | sha256sum }} spec: + automountServiceAccountToken: false volumes: - name: "backoffice-config" configMap: diff --git a/charts/calling-test/templates/deployment.yaml b/charts/calling-test/templates/deployment.yaml index a8acfa18b74..92f353e44d3 100644 --- a/charts/calling-test/templates/deployment.yaml +++ b/charts/calling-test/templates/deployment.yaml @@ -14,6 +14,7 @@ spec: labels: {{- include "calling-test.selectorLabels" . | nindent 8 }} spec: + automountServiceAccountToken: false containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}" diff --git a/charts/demo-smtp/templates/deployment.yaml b/charts/demo-smtp/templates/deployment.yaml index a24a4a0f3b0..ac3a9ff6634 100644 --- a/charts/demo-smtp/templates/deployment.yaml +++ b/charts/demo-smtp/templates/deployment.yaml @@ -19,6 +19,7 @@ spec: app: {{ template "demo-smtp.name" . }} release: {{ .Release.Name }} spec: + automountServiceAccountToken: false topologySpreadConstraints: - maxSkew: 1 topologyKey: "kubernetes.io/hostname" diff --git a/charts/elasticsearch-ephemeral/templates/es.yaml b/charts/elasticsearch-ephemeral/templates/es.yaml index 81832c6783b..ae8f5cfca5d 100644 --- a/charts/elasticsearch-ephemeral/templates/es.yaml +++ b/charts/elasticsearch-ephemeral/templates/es.yaml @@ -18,6 +18,7 @@ spec: labels: component: {{ template "fullname" . }} spec: + automountServiceAccountToken: false containers: - name: es image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" diff --git a/charts/fake-aws-ses/templates/deployment.yaml b/charts/fake-aws-ses/templates/deployment.yaml index cb972a5a7b0..229e544e7d2 100644 --- a/charts/fake-aws-ses/templates/deployment.yaml +++ b/charts/fake-aws-ses/templates/deployment.yaml @@ -17,6 +17,7 @@ spec: labels: app: {{ template "fullname" . }} spec: + automountServiceAccountToken: false topologySpreadConstraints: - maxSkew: 1 topologyKey: "kubernetes.io/hostname" diff --git a/charts/fake-aws-sns/templates/deployment.yaml b/charts/fake-aws-sns/templates/deployment.yaml index d85b4770d36..82a6e951b97 100644 --- a/charts/fake-aws-sns/templates/deployment.yaml +++ b/charts/fake-aws-sns/templates/deployment.yaml @@ -17,6 +17,7 @@ spec: labels: app: {{ template "fullname" . }} spec: + automountServiceAccountToken: false topologySpreadConstraints: - maxSkew: 1 topologyKey: "kubernetes.io/hostname" diff --git a/charts/fake-aws-sqs/templates/deployment.yaml b/charts/fake-aws-sqs/templates/deployment.yaml index c8e024632b1..d3521235843 100644 --- a/charts/fake-aws-sqs/templates/deployment.yaml +++ b/charts/fake-aws-sqs/templates/deployment.yaml @@ -19,6 +19,7 @@ spec: annotations: checksum/configmap: {{ include (print .Template.BasePath "/configmap.yaml") . | sha256sum }} spec: + automountServiceAccountToken: false topologySpreadConstraints: - maxSkew: 1 topologyKey: "kubernetes.io/hostname" diff --git a/charts/openldap/templates/openldap.yaml b/charts/openldap/templates/openldap.yaml index 3a0fdb9f08b..12274bdd601 100644 --- a/charts/openldap/templates/openldap.yaml +++ b/charts/openldap/templates/openldap.yaml @@ -8,6 +8,7 @@ metadata: release: {{ .Release.Name }} heritage: {{ .Release.Service }} spec: + automountServiceAccountToken: false topologySpreadConstraints: - maxSkew: 1 topologyKey: "kubernetes.io/hostname" diff --git a/charts/outlook-addin/templates/deployment.yaml b/charts/outlook-addin/templates/deployment.yaml index 3a0ab24413d..d204cb94643 100644 --- a/charts/outlook-addin/templates/deployment.yaml +++ b/charts/outlook-addin/templates/deployment.yaml @@ -15,6 +15,7 @@ spec: labels: app: {{ include "outlook.fullname" . }} spec: + automountServiceAccountToken: false topologySpreadConstraints: - maxSkew: 1 topologyKey: "kubernetes.io/hostname" diff --git a/hack/python/wire/frozendict.py b/hack/python/wire/frozendict.py index 58c6a9939ec..22c9a3ba741 100644 --- a/hack/python/wire/frozendict.py +++ b/hack/python/wire/frozendict.py @@ -1,3 +1,7 @@ +import functools + + +@functools.total_ordering class frozendict(dict): def __init__(self, data): super().__init__(data) From 299a1707178a70955315ff1feb06f8b22b475ef4 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Fri, 10 Jul 2026 22:34:29 +0200 Subject: [PATCH 009/113] WPB-26773: reject meetings with start time in the past (#5325) --- .../wpb-26773-meeting-start-not-past | 1 + integration/test/Test/Meetings.hs | 49 +++-- .../src/Wire/MeetingsSubsystem/Interpreter.hs | 20 ++ .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 199 ++++++++++++++---- 4 files changed, 210 insertions(+), 59 deletions(-) create mode 100644 changelog.d/1-api-changes/wpb-26773-meeting-start-not-past diff --git a/changelog.d/1-api-changes/wpb-26773-meeting-start-not-past b/changelog.d/1-api-changes/wpb-26773-meeting-start-not-past new file mode 100644 index 00000000000..3116adbf64f --- /dev/null +++ b/changelog.d/1-api-changes/wpb-26773-meeting-start-not-past @@ -0,0 +1 @@ +Reject meeting creation and update when the start time is in the past (with a 60-second tolerance for clock skew). Previously, meetings could be created with arbitrary past start times. diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs index 5355471b5cf..1feb0c0b898 100644 --- a/integration/test/Test/Meetings.hs +++ b/integration/test/Test/Meetings.hs @@ -162,6 +162,17 @@ testMeetingsConfigDisabledBlocksCreate = do postMeetings owner newMeeting >>= assertLabel 403 "invalid-op" +-- Test that creating a meeting with a start time in the past is rejected +testMeetingCreatePastStartTime :: (HasCallStack) => App () +testMeetingCreatePastStartTime = do + (owner, _tid, _members) <- createTeam OwnDomain 1 + now <- liftIO getCurrentTime + let startTime = addUTCTime (negate 3600) now + endTime = addUTCTime 3600 now + newMeeting = defaultMeetingJson "Past Meeting" startTime endTime [] + + postMeetings owner newMeeting >>= assertLabel 403 "invalid-op" + testMeetingRecurrence :: (HasCallStack) => App () testMeetingRecurrence = do (owner, _tid, _members) <- createTeam OwnDomain 1 @@ -473,15 +484,15 @@ testMeetingCleanup = do -- 2 minutes timeout (owner, _tid, _members) <- createTeam OwnDomain 1 now <- liftIO getCurrentTime - -- Create a meeting that ends now. + -- Create a meeting that starts now and ends 1s later. -- Configured retention is 0.0014 hours (~5 seconds). -- cutoffTime will be now' - 5s. -- We need end_date < cutoffTime. - -- If we wait 6 seconds, now' = now + 6s. - -- cutoffTime = now + 6s - 5s = now + 1s. - -- end_date (now) < cutoffTime (now + 1s). - let startTime = addUTCTime (negate 3600) now - endTime = now + -- If we wait 7 seconds, now' = now + 7s. + -- cutoffTime = now + 7s - 5s = now + 2s. + -- end_date (now + 1s) < cutoffTime (now + 2s). + let startTime = now + endTime = addUTCTime 1 now newMeeting = defaultMeetingJson "Cleanup Test" startTime endTime [] r1 <- postMeetings owner newMeeting @@ -489,8 +500,8 @@ testMeetingCleanup = do meeting <- getJSON 201 r1 (meetingId, domain) <- getMeetingIdAndDomain meeting - -- Wait 6 seconds to ensure meeting is old enough - liftIO $ threadDelay 6_000_000 + -- Wait 7 seconds to ensure meeting is old enough + liftIO $ threadDelay 7_000_000 -- Wait for cleanup job to run waitForCleanupJob OwnDomain @@ -538,9 +549,9 @@ testMeetingExpiration :: (HasCallStack) => App () testMeetingExpiration = do (owner, _tid, _members) <- createTeam OwnDomain 1 now <- liftIO getCurrentTime - let startTime = addUTCTime (negate 3600) now + let startTime = now -- meetingValidityPeriodSeconds is configured to 5 seconds in galley.integration.yaml - endTime = now + endTime = addUTCTime 1 now newMeeting = defaultMeetingJson "Expiring Meeting" startTime endTime [] r1 <- postMeetings owner newMeeting @@ -548,11 +559,11 @@ testMeetingExpiration = do meeting <- getJSON 201 r1 (meetingId, domain) <- getMeetingIdAndDomain meeting - -- Check it is accessible immediately (endDate = now, so valid until now + 5s) + -- Check it is accessible immediately (endTime = now+1, well within the 5s validity) getMeeting owner domain meetingId >>= assertStatus 200 - -- Wait 6 seconds - liftIO $ threadDelay 6_000_000 + -- Wait 7 seconds so endTime (now+1) is past the validity cutoff (now+7-5 = now+2) + liftIO $ threadDelay 7_000_000 -- Check it is expired getMeeting owner domain meetingId >>= assertStatus 404 @@ -564,11 +575,11 @@ testMeetingListRecurringNotExpired :: (HasCallStack) => App () testMeetingListRecurringNotExpired = do (owner, _tid, _members) <- createTeam OwnDomain 1 now <- liftIO getCurrentTime - -- endTime = now: it only becomes "past" after the threadDelay below; - -- the recurrence window stays open for 30 days. + -- startTime = now, endTime = now+1: the slot only becomes "past" after the + -- threadDelay below; the recurrence window stays open for 30 days. -- meetingValidityPeriodSeconds is 5s in galley.integration.yaml. - let startTime = addUTCTime (negate 3600) now - endTime = now + let startTime = now + endTime = addUTCTime 1 now recurrenceUntil = Time.formatTime Time.defaultTimeLocale "%FT%TZ" $ addUTCTime (30 * nominalDay) now recurrence = object @@ -588,8 +599,8 @@ testMeetingListRecurringNotExpired = do meeting <- postMeetings owner newMeeting >>= getJSON 201 (meetingId, domain) <- getMeetingIdAndDomain meeting - -- Wait beyond the validity period so a non-recurring meeting would expire. - liftIO $ threadDelay 6_000_000 + -- Wait 7s beyond the validity period so a non-recurring meeting would expire. + liftIO $ threadDelay 7_000_000 -- Still accessible directly. getMeeting owner domain meetingId >>= assertStatus 200 diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs index 77602276cad..c3361d1add5 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs @@ -17,6 +17,7 @@ module Wire.MeetingsSubsystem.Interpreter ( interpretMeetingsSubsystem, + startTimeTolerance, MeetingError (..), ) where @@ -58,6 +59,15 @@ import Wire.TeamSubsystem qualified as TeamSubsystem data MeetingError = InvalidTimes | EmptyUpdate | MeetingsFeatureDisabled deriving stock (Eq, Show) +-- | Tolerance applied when validating that a meeting's start time is not in +-- the past. The check always uses the server's clock ('Now.get') as the +-- reference; the client's clock is never trusted. The tolerance only absorbs +-- minor clock skew between client and server and the network/processing delay +-- between the client sending the request and the server observing it (matches +-- the 60s precedent used by SAML2). +startTimeTolerance :: NominalDiffTime +startTimeTolerance = 60 + -- | Whether a meeting is still alive at the given cutoff. A meeting is alive -- when its 'Store.effectiveEndTime' is at or after the cutoff, or 'Nothing' -- (open-ended recurrence, which never expires). @@ -115,6 +125,7 @@ createMeetingImpl :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member Now r, Member (Error MeetingError) r ) => Local UserId -> @@ -127,6 +138,10 @@ createMeetingImpl zUser newMeeting = do -- Validate that endTime > startTime when (newMeeting.endTime <= newMeeting.startTime) $ throw InvalidTimes + -- Validate that startTime is not in the past (within tolerance) + now <- Now.get + when (newMeeting.startTime < addUTCTime (negate startTimeTolerance) now) $ + throw InvalidTimes -- Determine trial status: personal users (no team) create trial meetings. -- The deprecated meetingsPremium feature flag no longer affects this; team @@ -202,6 +217,11 @@ updateMeetingImpl zUser meetingId update validityPeriod = do when (fromMaybe meeting.startTime update.startTime >= fromMaybe meeting.endTime update.endTime) $ lift $ throw InvalidTimes + -- Validate that the updated start time (if provided) is not in the past + for_ update.startTime $ \t -> + when (t < addUTCTime (negate startTimeTolerance) now) $ + lift $ + throw InvalidTimes guard $ meeting.creator == tUnqualified zUser updatedMeeting <- diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index 03fb7b2288a..3a0e9b7c2ba 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -38,7 +38,7 @@ import Polysemy.TinyLog (TinyLog) import System.Random (StdGen, mkStdGen) import Test.Hspec import Test.Hspec.QuickCheck (prop) -import Test.QuickCheck (counterexample, ioProperty, (.&&.), (===), (==>)) +import Test.QuickCheck (NonNegative, counterexample, getNonNegative, ioProperty, (.&&.), (===), (==>)) import Text.Email.Parser (unsafeEmailAddress) import Wire.API.Conversation (Access (InviteAccess, PrivateAccess), Conversation (metadata, qualifiedId), ConversationMetadata (cnvmAccess)) import Wire.API.Error (ErrorS) @@ -197,6 +197,60 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do result <- runTestStack now gen Map.empty def $ createMeeting zUser newMeeting result `shouldBe` Left InvalidTimes + it "fails to create a meeting if start time is in the past" $ do + let now = UTCTime (fromGregorian 2026 1 1) 0 + gen = mkStdGen 42 + uid = Id $ read "00000000-0000-0000-0000-000000000001" + zUser = toLocalUnsafe (Domain "wire.com") uid + newMeeting = + API.NewMeeting + { title = fromJust $ checked "Past Meeting", + startTime = addUTCTime (negate 3600) now, + endTime = addUTCTime 3600 now, + recurrence = Nothing, + invitedEmails = [] + } + + result <- runTestStack now gen Map.empty def $ createMeeting zUser newMeeting + result `shouldBe` Left InvalidTimes + + it "accepts a meeting whose start time is exactly at the tolerance boundary" $ do + let now = UTCTime (fromGregorian 2026 1 1) 0 + gen = mkStdGen 42 + uid = Id $ read "00000000-0000-0000-0000-000000000001" + zUser = toLocalUnsafe (Domain "wire.com") uid + -- Exactly `expectedStartTimeTolerance` in the past: the check is strict (`<`), + -- so the boundary itself is still accepted. + newMeeting = + API.NewMeeting + { title = fromJust $ checked "Boundary Meeting", + startTime = addUTCTime (negate expectedStartTimeTolerance) now, + endTime = addUTCTime 3600 now, + recurrence = Nothing, + invitedEmails = [] + } + + result <- runTestStack now gen Map.empty def $ createMeeting zUser newMeeting + result `shouldSatisfy` isRight + + it "rejects a meeting whose start time is just past the tolerance boundary" $ do + let now = UTCTime (fromGregorian 2026 1 1) 0 + gen = mkStdGen 42 + uid = Id $ read "00000000-0000-0000-0000-000000000001" + zUser = toLocalUnsafe (Domain "wire.com") uid + -- One second past the tolerance boundary. + newMeeting = + API.NewMeeting + { title = fromJust $ checked "Just Past Boundary Meeting", + startTime = addUTCTime (negate (expectedStartTimeTolerance + 1)) now, + endTime = addUTCTime 3600 now, + recurrence = Nothing, + invitedEmails = [] + } + + result <- runTestStack now gen Map.empty def $ createMeeting zUser newMeeting + result `shouldBe` Left InvalidTimes + describe "getMeeting access control" $ do let now = UTCTime (fromGregorian 2026 1 1) 0 gen = mkStdGen 42 @@ -215,14 +269,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do let newMeeting = API.NewMeeting { title = fromJust $ checked "Past Meeting", - startTime = addUTCTime (-7200) now, - endTime = addUTCTime (-5000) now, + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do meeting <- createMeeting zUser1 newMeeting + passTime validityWindow getMeeting zUser1 meeting.meeting.id result `shouldBe` Right Nothing @@ -381,18 +436,42 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do result `shouldBe` Left InvalidTimes + it "throws InvalidTimes when updating startTime to the past" $ do + let newMeeting = + API.NewMeeting + { title = fromJust $ checked "Original Meeting", + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + recurrence = Nothing, + invitedEmails = [] + } + + result <- runTestStack now gen Map.empty teamConfig $ do + meeting <- createMeeting zUser1 newMeeting + let update = + API.UpdateMeeting + { startTime = Just (addUTCTime (negate 3600) now), + endTime = Nothing, + title = Nothing, + recurrence = Nothing + } + updateMeeting zUser1 meeting.meeting.id update + + result `shouldBe` Left InvalidTimes + it "returns Nothing for expired meeting" $ do let newMeeting = API.NewMeeting { title = fromJust $ checked "Expired Meeting", - startTime = addUTCTime (-7200) now, - endTime = addUTCTime (-5000) now, + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do meeting <- createMeeting zUser1 newMeeting + passTime validityWindow updateMeeting zUser1 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing) result `shouldBe` Right Nothing @@ -440,15 +519,24 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do recurrence = Nothing, invitedEmails = [] } - effectiveStart = fromMaybe baseMeeting.startTime update.startTime - effectiveEnd = fromMaybe baseMeeting.endTime update.endTime - isNotEmpty = update /= API.UpdateMeeting Nothing Nothing Nothing Nothing + -- Clamp the updated start time so it is not in the past (within + -- tolerance). This avoids discarding QuickCheck-generated updates + -- whose arbitrary UTCTime is far from `now`. + sanitizedUpdate = + API.UpdateMeeting + (fmap (max (addUTCTime (negate 60) now)) update.startTime) + update.endTime + update.title + update.recurrence + effectiveStart = fromMaybe baseMeeting.startTime sanitizedUpdate.startTime + effectiveEnd = fromMaybe baseMeeting.endTime sanitizedUpdate.endTime + isNotEmpty = sanitizedUpdate /= API.UpdateMeeting Nothing Nothing Nothing Nothing hasValidTimes = effectiveStart < effectiveEnd in isNotEmpty && hasValidTimes ==> ioProperty $ do result <- runTestStack now gen Map.empty teamConfig $ do meeting <- createMeeting zUser1 baseMeeting - updated <- updateMeeting zUser1 meeting.meeting.id update + updated <- updateMeeting zUser1 meeting.meeting.id sanitizedUpdate pure (meeting.meeting.conversationId, updated) case result of Left err -> @@ -457,10 +545,10 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do pure $ counterexample "Expected Just meeting, got Nothing" False Right (convId, Just m) -> pure $ - m.meeting.title === fromMaybe baseMeeting.title update.title + m.meeting.title === fromMaybe baseMeeting.title sanitizedUpdate.title .&&. m.meeting.startTime === effectiveStart .&&. m.meeting.endTime === effectiveEnd - .&&. m.meeting.recurrence === fromMaybe baseMeeting.recurrence update.recurrence + .&&. m.meeting.recurrence === fromMaybe baseMeeting.recurrence sanitizedUpdate.recurrence .&&. m.meeting.conversationId === convId describe "deleteMeeting" $ do @@ -515,14 +603,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do let newMeeting = API.NewMeeting { title = fromJust $ checked "Expired Meeting", - startTime = addUTCTime (-7200) now, - endTime = addUTCTime (-5000) now, + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do meeting <- createMeeting zUser1 newMeeting + passTime validityWindow deleteMeeting zUser1 testConnId meeting.meeting.id result `shouldBe` Right False @@ -613,14 +702,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do let newMeeting = API.NewMeeting { title = fromJust $ checked "Expired Meeting", - startTime = addUTCTime (-7200) now, - endTime = addUTCTime (-5000) now, + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do meeting <- createMeeting zUser1 newMeeting + passTime validityWindow addInvitedEmails zUser1 meeting.meeting.id [email1] result `shouldBe` Right False @@ -739,14 +829,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do let newMeeting = API.NewMeeting { title = fromJust $ checked "Expired Meeting", - startTime = addUTCTime (-7200) now, - endTime = addUTCTime (-5000) now, + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, recurrence = Nothing, invitedEmails = [email1] } result <- runTestStack now gen Map.empty teamConfig $ do meeting <- createMeeting zUser1 newMeeting + passTime validityWindow removeInvitedEmails zUser1 meeting.meeting.id [email1] result `shouldBe` Right False @@ -865,14 +956,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do let newMeeting = API.NewMeeting { title = fromJust $ checked "Expired Meeting", - startTime = addUTCTime (-7200) now, - endTime = addUTCTime (-5000) now, + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, recurrence = Nothing, invitedEmails = [email1] } result <- runTestStack now gen Map.empty teamConfig $ do meeting <- createMeeting zUser1 newMeeting + passTime validityWindow replaceInvitedEmails zUser1 meeting.meeting.id [email2] result `shouldBe` Right False @@ -913,12 +1005,14 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do teamConfig = npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def - -- endTime (now-5000) is well past the validity cutoff (now-3600). - expiredNewMeeting r = + -- Meetings are created with future times. We then advance the mock + -- clock past the validity window (11000s) so that the original slot + -- has passed, while the recurrence window stays open. + futureMeeting r = API.NewMeeting { title = fromJust $ checked "Recurring Meeting", - startTime = addUTCTime (-7200) now, - endTime = addUTCTime (-5000) now, + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, recurrence = r, invitedEmails = [] } @@ -940,7 +1034,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do it "getMeeting returns a recurring meeting whose slot passed but window is open" $ do result <- runTestStack now gen Map.empty teamConfig $ do - meeting <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + meeting <- createMeeting zUser (futureMeeting boundedRecurrence) + passTime validityWindow getMeeting zUser meeting.meeting.id case result of Left err -> fail $ "Error: " <> show err @@ -950,7 +1045,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do it "listMeetings includes a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - _meeting <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + _meeting <- createMeeting zUser (futureMeeting boundedRecurrence) + passTime validityWindow listMeetings zUser case result of Left err -> fail $ "Error: " <> show err @@ -959,51 +1055,60 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do it "updateMeeting succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - meeting <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + meeting <- createMeeting zUser (futureMeeting boundedRecurrence) + passTime validityWindow updateMeeting zUser meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) fmap isJust result `shouldBe` Right True it "addInvitedEmails succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - meeting <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + meeting <- createMeeting zUser (futureMeeting boundedRecurrence) + passTime validityWindow addInvitedEmails zUser meeting.meeting.id [unsafeEmailAddress "user" "example.com"] result `shouldBe` Right True it "deleteMeeting succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - meeting <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + meeting <- createMeeting zUser (futureMeeting boundedRecurrence) + passTime validityWindow deleteMeeting zUser (ConnId "test-conv") meeting.meeting.id result `shouldBe` Right True it "removeInvitedEmails succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - meeting <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + meeting <- createMeeting zUser (futureMeeting boundedRecurrence) + passTime validityWindow removeInvitedEmails zUser meeting.meeting.id [unsafeEmailAddress "user" "example.com"] result `shouldBe` Right True it "replaceInvitedEmails succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - meeting <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + meeting <- createMeeting zUser (futureMeeting boundedRecurrence) + passTime validityWindow replaceInvitedEmails zUser meeting.meeting.id [unsafeEmailAddress "user" "example.com"] result `shouldBe` Right True it "getMeeting returns an open-ended recurring meeting indefinitely" $ do result <- runTestStack now gen Map.empty teamConfig $ do - meeting <- createMeeting zUser (expiredNewMeeting openEndedRecurrence) + meeting <- createMeeting zUser (futureMeeting openEndedRecurrence) + passTime validityWindow getMeeting zUser meeting.meeting.id fmap isJust result `shouldBe` Right True it "cleanupOldMeetings skips recurring meetings whose window is still open" $ do result <- runTestStack now gen Map.empty teamConfig $ do - recurring <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - _plain <- createMeeting zUser (expiredNewMeeting Nothing) - deleted <- cleanupOldMeetings (addUTCTime (negate 1) now) 100 + recurring <- createMeeting zUser (futureMeeting boundedRecurrence) + _plain <- createMeeting zUser (futureMeeting Nothing) + passTime validityWindow + -- cutoff is past the endTime (now+7200) so the non-recurring + -- meeting is picked up, but well before the recurrence window. + deleted <- cleanupOldMeetings (addUTCTime 7300 now) 100 remaining <- getMeeting zUser recurring.meeting.id pure (deleted, fmap (.id) remaining, recurring.meeting.id) case result of @@ -1016,8 +1121,11 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do it "cleanupOldMeetings never picks up open-ended recurring meetings" $ do result <- runTestStack now gen Map.empty teamConfig $ do - meeting <- createMeeting zUser (expiredNewMeeting openEndedRecurrence) - deleted <- cleanupOldMeetings now 100 + meeting <- createMeeting zUser (futureMeeting openEndedRecurrence) + passTime validityWindow + -- Even with a cutoff well past the endTime, open-ended + -- recurrence is never picked up. + deleted <- cleanupOldMeetings (addUTCTime validityWindow now) 100 remaining <- getMeeting zUser meeting.meeting.id pure (deleted, fmap (.id) remaining, meeting.meeting.id) case result of @@ -1027,9 +1135,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do remainingId `shouldBe` Just meetingId prop "aliveness follows effectiveEndTime across get/list/cleanup" $ - \(recurrence :: Maybe API.Recurrence) (endOffset :: Int) -> - let endTime = addUTCTime (fromIntegral endOffset) now - startTime = addUTCTime (negate 3600) endTime + \(recurrence :: Maybe API.Recurrence) (advance :: NonNegative Int) -> + let startTime = addUTCTime 3600 now + endTime = addUTCTime 7200 now nm = API.NewMeeting { title = fromJust $ checked "Recurring Meeting", @@ -1038,13 +1146,16 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do recurrence = recurrence, invitedEmails = [] } + advanceTime = fromIntegral (getNonNegative advance) :: NominalDiffTime + -- After advancing the clock, the validity cutoff moves forward. + cutoff = addUTCTime (advanceTime - 3600) now effEnd = maybe (Just endTime) (\r -> max endTime <$> r.until) recurrence - cutoff = addUTCTime (negate 3600) now alive = maybe True (>= cutoff) effEnd in ioProperty $ do result <- runTestStack now gen Map.empty teamConfig $ do meeting <- createMeeting zUser nm + passTime advanceTime fetched <- isJust <$> getMeeting zUser meeting.meeting.id listedCount <- length <$> listMeetings zUser deleted <- cleanupOldMeetings cutoff 100 @@ -1191,3 +1302,11 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do replaceInvitedEmails zUserTeam meeting.meeting.id [unsafeEmailAddress "test" "example.com"] result2 `shouldBe` Left MeetingsFeatureDisabled + +-- | Synchronize with 'Wire.MeetingsSubsystem.Interpreter.startTimeTolerance' +expectedStartTimeTolerance :: NominalDiffTime +expectedStartTimeTolerance = 60 + +-- | Validity window, beyond this one-time meeting belong to the past +validityWindow :: NominalDiffTime +validityWindow = 11000 From a64a4750738a822e1341ae07e418b60f73291eaf Mon Sep 17 00:00:00 2001 From: Sven Tennie Date: Tue, 14 Jul 2026 08:02:14 +0200 Subject: [PATCH 010/113] Fix sbomnix by updating 1.7.4 -> 1.8.0 (#5336) 1.7.4 wasn't compatible to nixpkgs 26.05 and thus broke the `#sbom` env. Upgrading to latest stable solves this issue. --- changelog.d/5-internal/fix-sbomnix | 2 + flake.lock | 76 ++++++++++++++++++++++-------- flake.nix | 2 +- 3 files changed, 59 insertions(+), 21 deletions(-) create mode 100644 changelog.d/5-internal/fix-sbomnix diff --git a/changelog.d/5-internal/fix-sbomnix b/changelog.d/5-internal/fix-sbomnix new file mode 100644 index 00000000000..b9c6f74c9e2 --- /dev/null +++ b/changelog.d/5-internal/fix-sbomnix @@ -0,0 +1,2 @@ +Fix `#sbom` Nix env / sbomnix usage by upgrading to latest stable version of +the latter. The issue was introduced by upgrading `nixpkgs` to 26.05. diff --git a/flake.lock b/flake.lock index f4c3b235f89..185f73c28e8 100644 --- a/flake.lock +++ b/flake.lock @@ -86,11 +86,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1746162366, - "narHash": "sha256-5SSSZ/oQkwfcAz/o/6TlejlVGqeK08wyREBQ5qFFPhM=", + "lastModified": 1761640442, + "narHash": "sha256-AtrEP6Jmdvrqiv4x2xa5mrtaIp3OEe8uBYCDZDS+hu8=", "owner": "nix-community", "repo": "flake-compat", - "rev": "0f158086a2ecdbb138cd0429410e44994f1b7e4b", + "rev": "4a56054d8ffc173222d09dad23adf4ba946c8884", "type": "github" }, "original": { @@ -104,11 +104,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1756770412, - "narHash": "sha256-+uWLQZccFHwqpGqr2Yt5VsW/PbeJVTn9Dk6SHWhNRPw=", + "lastModified": 1775087534, + "narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "4524271976b625a4a605beefd893f270620fd751", + "rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b", "type": "github" }, "original": { @@ -163,11 +163,11 @@ ] }, "locked": { - "lastModified": 1757239681, - "narHash": "sha256-E9spYi9lxm2f1zWQLQ7xQt8Xs2nWgr1T4QM7ZjLFphM=", + "lastModified": 1776796298, + "narHash": "sha256-PcRvlWayisPSjd0UcRQbhG8Oqw78AcPE6x872cPRHN8=", "owner": "cachix", "repo": "git-hooks.nix", - "rev": "ab82ab08d6bf74085bd328de2a8722c12d97bd9d", + "rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad", "type": "github" }, "original": { @@ -301,11 +301,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1754788789, - "narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=", + "lastModified": 1774748309, + "narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "a73b9c743612e4244d865a2fdee11865283c04e6", + "rev": "333c4e0545a6da976206c74db8773a1645b5870a", "type": "github" }, "original": { @@ -400,19 +400,19 @@ "nixpkgs": [ "nixpkgs" ], - "treefmt-nix": "treefmt-nix" + "vulnix": "vulnix" }, "locked": { - "lastModified": 1760339225, - "narHash": "sha256-pYZax5cxBHa+jcxTsQKEVHhXMtmvLGD1ISUyli2ZreU=", + "lastModified": 1780995381, + "narHash": "sha256-q91Amk0tTbACseVLmvd3LhBixcACyskVHD7lo1mSf28=", "owner": "tiiuae", "repo": "sbomnix", - "rev": "fe2a608c000127092b3d27c9cfd65c26f325bb28", + "rev": "30c95eb2fc0c402300ee05354c5cf84c7e5f74e2", "type": "github" }, "original": { "owner": "tiiuae", - "ref": "v1.7.4", + "ref": "v1.8.0", "repo": "sbomnix", "type": "github" } @@ -544,15 +544,16 @@ "inputs": { "nixpkgs": [ "sbomnix", + "vulnix", "nixpkgs" ] }, "locked": { - "lastModified": 1756662192, - "narHash": "sha256-F1oFfV51AE259I85av+MAia221XwMHCOtZCMcZLK2Jk=", + "lastModified": 1775636079, + "narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "1aabc6c05ccbcbf4a635fb7a90400e44282f61c4", + "rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba", "type": "github" }, "original": { @@ -561,6 +562,41 @@ "type": "github" } }, + "vulnix": { + "inputs": { + "flake-compat": [ + "sbomnix", + "flake-compat" + ], + "flake-parts": [ + "sbomnix", + "flake-parts" + ], + "flake-root": [ + "sbomnix", + "flake-root" + ], + "nixpkgs": [ + "sbomnix", + "nixpkgs" + ], + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1778651554, + "narHash": "sha256-QxkkaBVdIIot/YVxPuzUhjP7cDAp7U9iJXWozVTcuww=", + "owner": "nix-community", + "repo": "vulnix", + "rev": "038b06e335c41d7d2dcbccbf4f821f95d7c17b16", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "master", + "repo": "vulnix", + "type": "github" + } + }, "wai-predicates": { "flake": false, "locked": { diff --git a/flake.nix b/flake.nix index 5206ca874e8..24c2cf99510 100644 --- a/flake.nix +++ b/flake.nix @@ -12,7 +12,7 @@ inputs.flake-utils.follows = "flake-utils"; }; sbomnix = { - url = "github:tiiuae/sbomnix/v1.7.4"; + url = "github:tiiuae/sbomnix/v1.8.0"; inputs.nixpkgs.follows = "nixpkgs"; }; From 90fc9ce43edfaa3b739c0895b5bff5c4aad0609d Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Tue, 14 Jul 2026 15:57:03 +0200 Subject: [PATCH 011/113] [WPB-25521] Fix update / delete collaborator routes (#5334) Also move collaborator CRUD api to galley. --- .../WPB-25521-finish-collaborator-crud-api | 1 + charts/nginz/values.yaml | 12 +++++----- integration/test/API/Brig.hs | 16 -------------- integration/test/API/Galley.hs | 16 ++++++++++++++ integration/test/Test/TeamCollaborators.hs | 1 - .../src/Wire/API/Routes/Public/Brig.hs | 22 ------------------- .../API/Routes/Public/Galley/TeamMember.hs | 21 ++++++++++++++++++ services/brig/src/Brig/API/Public.hs | 2 -- services/brig/src/Brig/Team/API.hs | 5 ----- .../src/Galley/API/Public/TeamMember.hs | 5 +++++ .../integration-test/conf/nginz/nginx.conf | 15 ++++++++----- 11 files changed, 59 insertions(+), 57 deletions(-) create mode 100644 changelog.d/3-bug-fixes/WPB-25521-finish-collaborator-crud-api diff --git a/changelog.d/3-bug-fixes/WPB-25521-finish-collaborator-crud-api b/changelog.d/3-bug-fixes/WPB-25521-finish-collaborator-crud-api new file mode 100644 index 00000000000..a692802fd55 --- /dev/null +++ b/changelog.d/3-bug-fixes/WPB-25521-finish-collaborator-crud-api @@ -0,0 +1 @@ +Fix nginz routes for delete / update collaborator. Move collaborator CRUD api to galley. diff --git a/charts/nginz/values.yaml b/charts/nginz/values.yaml index bbeb433b3e6..10140fa1f34 100644 --- a/charts/nginz/values.yaml +++ b/charts/nginz/values.yaml @@ -446,12 +446,6 @@ nginx_conf: - path: /search envs: - all - - path: /teams/([^/]*)/collaborators$ - envs: - - all - - path: /teams/([^/]*)/collaborators/([^/]*) - envs: - - all - path: /teams/([^/]*)/apps$ envs: - all @@ -685,6 +679,12 @@ nginx_conf: - path: /teams/([^/]*)/channels/search$ envs: - all + - path: /teams/([^/]*)/collaborators$ + envs: + - all + - path: /teams/([^/]*)/collaborators/([^/]*) + envs: + - all - path: /teams/([^/]*)/members(.*) envs: - all diff --git a/integration/test/API/Brig.hs b/integration/test/API/Brig.hs index 70e74fce45e..c3e0decf671 100644 --- a/integration/test/API/Brig.hs +++ b/integration/test/API/Brig.hs @@ -1218,22 +1218,6 @@ removeUserFromGroup user gid uid = do req <- baseRequest user Brig Versioned $ joinHttpPath ["user-groups", gid, "users", uid] submit "DELETE" req -addTeamCollaborator :: (MakesValue owner, MakesValue collaborator, HasCallStack) => owner -> String -> collaborator -> [String] -> App Response -addTeamCollaborator owner tid collaborator permissions = do - req <- baseRequest owner Brig Versioned $ joinHttpPath ["teams", tid, "collaborators"] - (_, collabId) <- objQid collaborator - submit "POST" $ - req - & addJSONObject - [ "user" .= collabId, - "permissions" .= permissions - ] - -getAllTeamCollaborators :: (MakesValue owner) => owner -> String -> App Response -getAllTeamCollaborators owner tid = do - req <- baseRequest owner Brig Versioned $ joinHttpPath ["teams", tid, "collaborators"] - submit "GET" req - data NewApp = NewApp { name :: String, assets :: Maybe [Value], diff --git a/integration/test/API/Galley.hs b/integration/test/API/Galley.hs index 658b6275c59..480d6c79f6d 100644 --- a/integration/test/API/Galley.hs +++ b/integration/test/API/Galley.hs @@ -967,6 +967,22 @@ resetConversation user groupId epoch = do let payload = object ["group_id" .= groupId, "epoch" .= epoch] submit "POST" $ req & addJSON payload +addTeamCollaborator :: (MakesValue owner, MakesValue collaborator, HasCallStack) => owner -> String -> collaborator -> [String] -> App Response +addTeamCollaborator owner tid collaborator permissions = do + req <- baseRequest owner Galley Versioned $ joinHttpPath ["teams", tid, "collaborators"] + (_, collabId) <- objQid collaborator + submit "POST" + $ req + & addJSONObject + [ "user" .= collabId, + "permissions" .= permissions + ] + +getAllTeamCollaborators :: (MakesValue owner) => owner -> String -> App Response +getAllTeamCollaborators owner tid = do + req <- baseRequest owner Galley Versioned $ joinHttpPath ["teams", tid, "collaborators"] + submit "GET" req + updateTeamCollaborator :: (MakesValue owner, MakesValue collaborator, HasCallStack) => owner -> String -> collaborator -> [String] -> App Response updateTeamCollaborator owner tid collaborator permissions = do (_, collabId) <- objQid collaborator diff --git a/integration/test/Test/TeamCollaborators.hs b/integration/test/Test/TeamCollaborators.hs index 7286d1224c0..cf55c3a558e 100644 --- a/integration/test/Test/TeamCollaborators.hs +++ b/integration/test/Test/TeamCollaborators.hs @@ -17,7 +17,6 @@ module Test.TeamCollaborators where -import API.Brig import API.Galley import qualified API.GalleyInternal as Internal import Data.Tuple.Extra diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs index 16b4bc0e09b..8fe7484156b 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -70,7 +70,6 @@ import Wire.API.Routes.QualifiedCapture import Wire.API.Routes.Version import Wire.API.Routes.Versioned import Wire.API.SystemSettings -import Wire.API.Team.Collaborator import Wire.API.Team.Invitation import Wire.API.Team.Size import Wire.API.User hiding (NoIdentity) @@ -2097,27 +2096,6 @@ type TeamsAPI = :> ReqBody '[JSON] AcceptTeamInvitation :> MultiVerb 'POST '[JSON] '[RespondEmpty 200 "Team invitation accepted."] () ) - :<|> Named - "add-team-collaborator" - ( Summary "Add a collaborator to the team." - :> From 'V10 - :> ZLocalUser - :> "teams" - :> Capture "tid" TeamId - :> "collaborators" - :> ReqBody '[JSON] NewTeamCollaborator - :> MultiVerb1 'POST '[JSON] (RespondEmpty 200 "") - ) - :<|> Named - "get-team-collaborators" - ( Summary "Get all collaborators of the team." - :> From 'V10 - :> ZLocalUser - :> "teams" - :> Capture "tid" TeamId - :> "collaborators" - :> MultiVerb1 'GET '[JSON] (Respond 200 "Return collaborators" [TeamCollaborator]) - ) type SystemSettingsAPI = Named diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs index e28ac848bbc..33044bfcc0a 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs @@ -209,6 +209,27 @@ type TeamMemberAPI = "CSV of team members" CSV ) + :<|> Named + "add-team-collaborator" + ( Summary "Add a collaborator to the team." + :> From 'V10 + :> ZLocalUser + :> "teams" + :> Capture "tid" TeamId + :> "collaborators" + :> ReqBody '[JSON] NewTeamCollaborator + :> MultiVerb1 'POST '[JSON] (RespondEmpty 200 "") + ) + :<|> Named + "get-team-collaborators" + ( Summary "Get all collaborators of the team." + :> From 'V10 + :> ZLocalUser + :> "teams" + :> Capture "tid" TeamId + :> "collaborators" + :> MultiVerb1 'GET '[JSON] (Respond 200 "Return collaborators" [TeamCollaborator]) + ) :<|> Named "update-team-collaborator" ( Summary "Update a collaborator permissions from the team." diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 8173ad1568c..875f1d0bd55 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -192,7 +192,6 @@ import Wire.Sem.Paging.Cassandra import Wire.Sem.Random (Random) import Wire.SessionStore (SessionStore) import Wire.SparAPIAccess -import Wire.TeamCollaboratorsSubsystem import Wire.TeamInvitationSubsystem import Wire.TeamSubsystem (TeamSubsystem) import Wire.TeamSubsystem qualified as TeamSubsystem @@ -416,7 +415,6 @@ servantSitemap :: Member CryptoSign r, Member Random r, Member UserGroupSubsystem r, - Member TeamCollaboratorsSubsystem r, Member TeamSubsystem r, Member AppSubsystem r, Member ClientStore r, diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs index a612f9ec371..bda4bd49134 100644 --- a/services/brig/src/Brig/Team/API.hs +++ b/services/brig/src/Brig/Team/API.hs @@ -62,7 +62,6 @@ import Wire.API.Routes.Internal.Galley.TeamsIntra qualified as Team import Wire.API.Routes.Named import Wire.API.Routes.Public.Brig (TeamsAPI) import Wire.API.Team -import Wire.API.Team.Collaborator import Wire.API.Team.Invitation import Wire.API.Team.Invitation qualified as Public (Invitation (..)) import Wire.API.Team.Member (teamMembers) @@ -81,7 +80,6 @@ import Wire.IndexedUserStore (IndexedUserStore, getTeamSize) import Wire.InvitationStore (InvitationStore (..), PaginatedResult (..), StoredInvitation (..)) import Wire.InvitationStore qualified as Store import Wire.Sem.Concurrency -import Wire.TeamCollaboratorsSubsystem import Wire.TeamInvitationSubsystem import Wire.TeamInvitationSubsystem.Interpreter (toInvitation) import Wire.TeamSubsystem (TeamSubsystem) @@ -101,7 +99,6 @@ servantAPI :: Member (Input (Local ())) r, Member (Error UserSubsystemError) r, Member IndexedUserStore r, - Member TeamCollaboratorsSubsystem r, Member TeamSubsystem r ) => ServerT TeamsAPI (Handler r) @@ -115,8 +112,6 @@ servantAPI = :<|> Named @"head-team-invitations" (lift . liftSem . headInvitationByEmail) :<|> Named @"get-team-size" (\uid tid -> lift . liftSem $ teamSizePublic uid tid) :<|> Named @"accept-team-invitation" (\luid req -> lift $ liftSem $ acceptTeamInvitation luid req.password req.code) - :<|> Named @"add-team-collaborator" (\zuid tid (NewTeamCollaborator uid perms) -> lift . liftSem $ createTeamCollaborator zuid uid tid perms) - :<|> Named @"get-team-collaborators" (\zuid tid -> lift . liftSem $ getAllTeamCollaborators zuid tid) teamSizePublic :: ( Member (Error UserSubsystemError) r, diff --git a/services/galley/src/Galley/API/Public/TeamMember.hs b/services/galley/src/Galley/API/Public/TeamMember.hs index d85d89f514e..5c56816012e 100644 --- a/services/galley/src/Galley/API/Public/TeamMember.hs +++ b/services/galley/src/Galley/API/Public/TeamMember.hs @@ -22,6 +22,8 @@ import Galley.API.Teams.Export qualified as Export import Galley.App import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.TeamMember +import Wire.API.Team.Collaborator +import Wire.TeamCollaboratorsSubsystem teamMemberAPI :: API TeamMemberAPI GalleyEffects teamMemberAPI = @@ -33,5 +35,8 @@ teamMemberAPI = <@> mkNamedAPI @"delete-non-binding-team-member" deleteNonBindingTeamMember <@> mkNamedAPI @"update-team-member" updateTeamMember <@> mkNamedAPI @"get-team-members-csv" Export.getTeamMembersCSV + <@> mkNamedAPI @"add-team-collaborator" + (\zuid tid (NewTeamCollaborator uid perms) -> createTeamCollaborator zuid uid tid perms) + <@> mkNamedAPI @"get-team-collaborators" getAllTeamCollaborators <@> mkNamedAPI @"update-team-collaborator" updateTeamCollaborator <@> mkNamedAPI @"remove-team-collaborator" removeTeamCollaborator diff --git a/services/nginz/integration-test/conf/nginz/nginx.conf b/services/nginz/integration-test/conf/nginz/nginx.conf index 937345a5d9f..a6e10ecf9ea 100644 --- a/services/nginz/integration-test/conf/nginz/nginx.conf +++ b/services/nginz/integration-test/conf/nginz/nginx.conf @@ -416,11 +416,6 @@ http { proxy_pass http://brig; } - location ~* ^(/v[0-9]+)?/teams/([^/]*)/collaborators$ { - include common_response_with_zauth.conf; - proxy_pass http://brig; - } - location ~* ^(/v[0-9]+)?/teams/([^/]*)/apps { include common_response_with_zauth.conf; proxy_pass http://brig; @@ -445,6 +440,16 @@ http { # Galley Endpoints + location ~* ^(/v[0-9]+)?/teams/([^/]*)/collaborators$ { + include common_response_with_zauth.conf; + proxy_pass http://galley; + } + + location ~* ^(/v[0-9]+)?/teams/([^/]*)/collaborators/([^/]*)$ { + include common_response_with_zauth.conf; + proxy_pass http://galley; + } + location ~* ^(/v[0-9]+)?/legalhold/conversations/(.*)$ { include common_response_with_zauth.conf; proxy_pass http://galley; From 8b7cd5192b0c437eaf87e7efc2cfd651005f3c2c Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Wed, 15 Jul 2026 08:03:30 +0200 Subject: [PATCH 012/113] [fix] team search visibility JSON parser (#5338) --- .../3-bug-fixes/search-visibility-feature-key | 1 + libs/wire-api/default.nix | 1 + libs/wire-api/src/Wire/API/Team/FeatureFlags.hs | 7 ++++++- .../test/unit/Test/Wire/API/Roundtrip/Aeson.hs | 17 +++++++++++++++-- libs/wire-api/wire-api.cabal | 1 + 5 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 changelog.d/3-bug-fixes/search-visibility-feature-key diff --git a/changelog.d/3-bug-fixes/search-visibility-feature-key b/changelog.d/3-bug-fixes/search-visibility-feature-key new file mode 100644 index 00000000000..e8afbcf1ccc --- /dev/null +++ b/changelog.d/3-bug-fixes/search-visibility-feature-key @@ -0,0 +1 @@ +Fixed a discrepancy between the canonical `searchVisibility` feature key used in runtime JSON and the legacy `teamSearchVisibility` key used in YAML configuration. Both names are now accepted; no configuration changes or other operator actions are required. diff --git a/libs/wire-api/default.nix b/libs/wire-api/default.nix index 5671728c638..f70172b2fd3 100644 --- a/libs/wire-api/default.nix +++ b/libs/wire-api/default.nix @@ -252,6 +252,7 @@ mkDerivation { crypton crypton-pem currency-codes + data-default filepath hex hspec diff --git a/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs b/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs index 6a77beeb88e..82d6bea2b08 100644 --- a/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs +++ b/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs @@ -166,7 +166,12 @@ instance Default (FeatureDefaults SearchVisibilityAvailableConfig) where def = FeatureTeamSearchVisibilityAvailableByDefault instance ParseFeatureDefaults (FeatureDefaults SearchVisibilityAvailableConfig) where - parseFeatureDefaults obj = obj .: "teamSearchVisibility" + parseFeatureDefaults obj = do + -- Runtime feature JSON uses the canonical feature key. Keep accepting the + -- legacy configuration key used by existing service YAML files. + mCanonical <- obj .:? "searchVisibility" + mLegacy <- obj .:? "teamSearchVisibility" + pure $ fromMaybe def (mCanonical <|> mLegacy) instance FromJSON (FeatureDefaults SearchVisibilityAvailableConfig) where parseJSON (String "enabled-by-default") = pure FeatureTeamSearchVisibilityAvailableByDefault diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs index ab1c1aea3c6..d7a7a647eb5 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs @@ -17,13 +17,15 @@ module Test.Wire.API.Roundtrip.Aeson (tests) where -import Data.Aeson (FromJSON, ToJSON, parseJSON, toJSON) +import Data.Aeson (FromJSON, Result (..), ToJSON, fromJSON, parseJSON, toJSON) import Data.Aeson.Types (parseEither) +import Data.Default (def) import Data.Id (ConvId) import Data.Misc import Data.OpenApi (ToSchema, validatePrettyToJSON) import Imports import Test.Tasty qualified as T +import Test.Tasty.HUnit (assertEqual, assertFailure, testCase) import Test.Tasty.QuickCheck (Arbitrary, counterexample, testProperty, (.&&.), (===)) import Type.Reflection (typeRep) import Wire.API.Asset qualified as Asset @@ -60,6 +62,7 @@ import Wire.API.SystemSettings qualified as SystemSettings import Wire.API.Team qualified as Team import Wire.API.Team.Conversation qualified as Team.Conversation import Wire.API.Team.Feature qualified as Team.Feature +import Wire.API.Team.FeatureFlags qualified as Team.FeatureFlags import Wire.API.Team.Invitation qualified as Team.Invitation import Wire.API.Team.LegalHold qualified as Team.LegalHold import Wire.API.Team.LegalHold.External qualified as Team.LegalHold.External @@ -379,9 +382,19 @@ tests = testRoundTrip @User.UpdateConnectionsInternal, testRoundTrip @Team.TeamSize, testRoundTrip @Team.LegalHold.Internal.LegalHoldService, - testRoundTrip @Team.LegalHold.Internal.LegalHoldClientRequest + testRoundTrip @Team.LegalHold.Internal.LegalHoldClientRequest, + testFeatureFlagsCanonicalJsonRoundtrip ] +testFeatureFlagsCanonicalJsonRoundtrip :: T.TestTree +testFeatureFlagsCanonicalJsonRoundtrip = + testCase "FeatureFlags accepts its canonical JSON representation" $ + case fromJSON (toJSON expected) of + Error err -> assertFailure $ "Could not decode canonical FeatureFlags JSON: " <> err + Success actual -> assertEqual "Roundtrip result should be the same as the original" actual expected + where + expected = def @Team.FeatureFlags.FeatureFlags + testRoundTrip :: forall a. (Arbitrary a, Typeable a, ToJSON a, FromJSON a, Eq a, Show a) => diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index 1ef9703bef6..835617908cb 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -742,6 +742,7 @@ test-suite wire-api-tests , cassava , containers >=0.5 , crypton + , data-default , filepath , hex , hspec From e1455dc57bedc6374cf52b1945d66a0c9f05aa04 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 15 Jul 2026 09:00:02 +0200 Subject: [PATCH 013/113] WPB-21744: invalidate pending email update when a user is put under SCIM control (#5333) When a user's managed_by transitioned from Wire to SCIM, a pending email-address update was left dangling: team settings kept offering a "resend verification" action that failed with 403 managed-by-scim, and a stale activation link could still change a SCIM-managed user's email outside of SCIM. - Add internal brig endpoint DELETE /i/users/:uid/pending-email-update that removes the user's unvalidated email and its activation token. - Call it from spar's two Wire->SCIM transition sites (lazy SCIM adoption via GET /Users/:id and getUserById). - Add defense-in-depth: brig's email-activation path now rejects SCIM-managed users (InvalidActivationManagedByScim -> 403 managed-by-scim). - Add ActivationCodeStore.deleteActivationCode (effect + Cassandra + in-memory impls). * Hello CI * fix(sven): add tests * Hello CI --- changelog.d/3-bug-fixes/WPB-21744 | 1 + integration/test/Test/Spar.hs | 44 +++++++++++++++++++ .../src/Wire/API/Routes/Internal/Brig.hs | 13 ++++++ .../src/Wire/ActivationCodeStore.hs | 6 +++ .../src/Wire/ActivationCodeStore/Cassandra.hs | 13 ++++++ .../wire-subsystems/src/Wire/BrigAPIAccess.hs | 1 + .../src/Wire/BrigAPIAccess/Rpc.hs | 14 ++++++ .../MockInterpreters/ActivationCodeStore.hs | 24 +++++----- services/brig/src/Brig/API/Internal.hs | 17 +++++++ services/spar/src/Spar/Scim/User.hs | 8 +++- 10 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 changelog.d/3-bug-fixes/WPB-21744 diff --git a/changelog.d/3-bug-fixes/WPB-21744 b/changelog.d/3-bug-fixes/WPB-21744 new file mode 100644 index 00000000000..3825ca54830 --- /dev/null +++ b/changelog.d/3-bug-fixes/WPB-21744 @@ -0,0 +1 @@ +When a user is put under SCIM control, any pending email-address update is now invalidated (the unvalidated email and its activation token are removed). Previously, team settings kept offering a "resend verification" action that could not succeed (failing with `403 managed-by-scim`), and a stale activation link could still change a SCIM-managed user's email outside of SCIM. diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index 26cd2f43207..fff111393d5 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -1564,3 +1564,47 @@ testScimUserChangeNameOnRegisteringIgnoredV16 = do registerUserWithVersioned (ExplicitVersion 16) OwnDomain email code newProfilename `bindResponse` \resp -> do resp.status `shouldMatchInt` 201 resp.json %. "name" `shouldMatch` scimUserDisplayName + +-- | A pending email update (emailUnvalidated + activation code) must be +-- invalidated when the user transitions to SCIM control. Getting a Wire-managed +-- user via the SCIM API triggers 'getUserById' -> 'synthesizeStoredUser' -> +-- 'ManagedByScim' -> 'deletePendingEmailUpdate' in spar, which calls the brig +-- internal endpoint that deletes both the activation code and the pending +-- email entry. See WPB-21744 / PR #5333. +testSparScimInvalidatesPendingEmail :: (HasCallStack) => App () +testSparScimInvalidatesPendingEmail = do + -- 1. Create a team with an owner and one Wire-managed member. + (owner, _tid, [mem]) <- createTeam OwnDomain 2 + memberId <- mem %. "id" >>= asString + memberEmail <- mem %. "email" >>= asString + + -- 2. Login as the member and initiate an email update. This creates a + -- pending emailUnvalidated entry plus an activation code for the new email. + (cookie, token) <- + login mem memberEmail defPassword `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + tok <- resp.json %. "access_token" & asString + let c = fromJust $ getCookie "zuid" resp + pure ("zuid=" <> c, tok) + newEmail <- randomEmail + updateEmail mem newEmail cookie token >>= assertSuccess + + -- 3. Verify the activation code exists for the pending (unvalidated) email. + getActivationCode OwnDomain newEmail >>= assertStatus 200 + + -- 4. Transition the member to SCIM control. Reading the user through the + -- SCIM API triggers getUserById -> synthesizeStoredUser -> (since the user + -- is email-only and not yet SCIM-managed) setManagedBy ManagedByScim and + -- deletePendingEmailUpdate. + tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString + getScimUser OwnDomain tok memberId >>= assertStatus 200 + + -- 5. The activation code for the pending email has been invalidated. + getActivationCode OwnDomain newEmail >>= assertStatus 404 + + -- 6. The member's email is unchanged: the SCIM transition must not promote + -- the unvalidated email, only drop the pending update. + bindResponse (getUsersId OwnDomain [memberId]) $ \resp -> do + resp.status `shouldMatchInt` 200 + u <- resp.json & asList >>= assertOne + u %. "email" `shouldMatch` memberEmail diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs index 4a0e907c808..0bf24d834c0 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs @@ -535,6 +535,19 @@ type AccountAPI = :> ReqBody '[Servant.JSON] ManagedByUpdate :> Put '[Servant.JSON] NoContent ) + :<|> Named + "iDeletePendingEmailUpdate" + ( Summary + "Invalidate a pending email-address update for a user. \ + \Used by spar when a user is put under SCIM control, so that team \ + \settings no longer offers a 'resend verification' action that cannot \ + \succeed, and stale activation links cannot change a SCIM-managed \ + \user's email." + :> "users" + :> Capture "uid" UserId + :> "pending-email-update" + :> Delete '[Servant.JSON] NoContent + ) :<|> Named "iPutRichInfo" ( "users" diff --git a/libs/wire-subsystems/src/Wire/ActivationCodeStore.hs b/libs/wire-subsystems/src/Wire/ActivationCodeStore.hs index 06b331cebb1..0b6f656e363 100644 --- a/libs/wire-subsystems/src/Wire/ActivationCodeStore.hs +++ b/libs/wire-subsystems/src/Wire/ActivationCodeStore.hs @@ -54,5 +54,11 @@ data ActivationCodeStore :: Effect where -- | The user with whom to associate the activation code. Maybe UserId -> ActivationCodeStore m Activation + -- | Delete a pending activation code for a given 'EmailKey', if any. + -- This is used to invalidate a pending email-address update (e.g. when a + -- user is put under SCIM control). + DeleteActivationCode :: + EmailKey -> + ActivationCodeStore m () makeSem ''ActivationCodeStore diff --git a/libs/wire-subsystems/src/Wire/ActivationCodeStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ActivationCodeStore/Cassandra.hs index 56ff6f247a5..24d2f3c2737 100644 --- a/libs/wire-subsystems/src/Wire/ActivationCodeStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/ActivationCodeStore/Cassandra.hs @@ -42,6 +42,7 @@ interpretActivationCodeStoreToCassandra casClient = liftIO (mkActivationKey ek) >>= retry x1 . query1 cql . params LocalQuorum . Identity NewActivationCode ek timeout uid -> newActivationCodeImpl ek timeout uid + DeleteActivationCode ek -> deleteActivationCodeImpl ek where cql :: PrepQuery R (Identity ActivationKey) (Maybe UserId, ActivationCode) cql = @@ -72,6 +73,15 @@ newActivationCodeImpl uk timeout u = do ActivationCode . Ascii.unsafeFromText . pack . printf "%06d" <$> randIntegerZeroToNMinusOne 1000000 +-- | Delete a pending activation code for a given 'EmailKey', if any. +deleteActivationCodeImpl :: + (MonadClient m) => + EmailKey -> + m () +deleteActivationCodeImpl uk = do + key <- liftIO $ mkActivationKey uk + retry x5 . write keyDelete $ params LocalQuorum (Identity key) + -------------------------------------------------------------------------------- -- Utilities @@ -91,6 +101,9 @@ keyInsert = \(key, key_type, key_text, code, user, retries) VALUES \ \(? , ? , ? , ? , ? , ? ) USING TTL ?" +keyDelete :: PrepQuery W (Identity ActivationKey) () +keyDelete = "DELETE FROM activation_keys WHERE key = ?" + -- | Max. number of activation attempts per 'ActivationKey'. maxAttempts :: Int32 maxAttempts = 3 diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs index ceec1624944..972df7782cf 100644 --- a/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs @@ -151,6 +151,7 @@ data BrigAPIAccess m a where SetName :: UserId -> Name -> BrigAPIAccess m () SetHandle :: UserId -> Handle -> BrigAPIAccess m () SetManagedBy :: UserId -> ManagedBy -> BrigAPIAccess m () + DeletePendingEmailUpdate :: UserId -> BrigAPIAccess m () SetSSOId :: UserId -> UserSSOId -> BrigAPIAccess m () SetRichInfo :: UserId -> RichInfo -> BrigAPIAccess m () SetLocale :: UserId -> Maybe Locale -> BrigAPIAccess m () diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs index f4bd3d9ea47..0c754c43f5a 100644 --- a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs @@ -167,6 +167,8 @@ interpretBrigAccess brigEndpoint = setHandle uid handle SetManagedBy uid managedBy -> setManagedBy uid managedBy + DeletePendingEmailUpdate uid -> + deletePendingEmailUpdate uid SetSSOId uid ssoId -> setSSOId uid ssoId SetRichInfo uid richInfo -> @@ -996,6 +998,18 @@ setManagedBy buid managedBy = do unless (statusCode resp == 200) $ rethrow "brig" resp +deletePendingEmailUpdate :: + (Member Rpc r, Member (Input Endpoint) r, Member (Error RpcException) r) => + UserId -> + Sem r () +deletePendingEmailUpdate buid = do + resp <- + brigRequest $ + method DELETE + . paths ["i", "users", toByteString' buid, "pending-email-update"] + unless (statusCode resp == 200) $ + rethrow "brig" resp + setSSOId :: (Member Rpc r, Member (Input Endpoint) r, Member (Error RpcException) r) => UserId -> diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ActivationCodeStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ActivationCodeStore.hs index dfb21478e8c..7250d3047fa 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ActivationCodeStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ActivationCodeStore.hs @@ -42,14 +42,16 @@ emailKeyToCode = inMemoryActivationCodeStoreInterpreter :: (Member (State (Map EmailKey (Maybe UserId, ActivationCode))) r) => InterpreterFor ActivationCodeStore r -inMemoryActivationCodeStoreInterpreter = interpret \case - LookupActivationCode ek -> gets (!? ek) - NewActivationCode ek _ uid -> do - let key = - ActivationKey - . Ascii.encodeBase64Url - . T.encodeUtf8 - . emailKeyUniq - $ ek - c = emailKeyToCode ek - modify (insert ek (uid, c)) $> Activation key c +inMemoryActivationCodeStoreInterpreter = + interpret \case + LookupActivationCode ek -> gets (!? ek) + NewActivationCode ek _ uid -> do + let key = + ActivationKey + . Ascii.encodeBase64Url + . T.encodeUtf8 + . emailKeyUniq + $ ek + c = emailKeyToCode ek + modify (insert ek (uid, c)) $> Activation key c + DeleteActivationCode ek -> modify (delete ek) diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 6a6f17dbb80..f175554db71 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -90,6 +90,7 @@ import Wire.API.UserGroup (UserGroup) import Wire.API.UserGroup.Pagination import Wire.API.UserMap import Wire.ActivationCodeStore (ActivationCodeStore) +import Wire.ActivationCodeStore qualified as ActivationCode import Wire.AppStore (AppStore) import Wire.AppStore qualified as AppStore import Wire.AppSubsystem (AppSubsystem) @@ -128,6 +129,7 @@ import Wire.Sem.Concurrency import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) import Wire.SparAPIAccess (SparAPIAccess) +import Wire.StoredUser (StoredUser (emailUnvalidated)) import Wire.TeamInvitationSubsystem import Wire.TeamSubsystem (TeamSubsystem) import Wire.UserGroupSubsystem @@ -282,6 +284,7 @@ accountAPI = :<|> Named @"iPutUserSsoId" updateSSOIdH :<|> Named @"iDeleteUserSsoId" deleteSSOIdH :<|> Named @"iPutManagedBy" updateManagedByH + :<|> Named @"iDeletePendingEmailUpdate" deletePendingEmailUpdateH :<|> Named @"iPutRichInfo" updateRichInfoH :<|> Named @"iPutHandle" updateHandleH :<|> Named @"iPutUserName" updateUserNameH @@ -898,6 +901,20 @@ updateManagedByH :: (Member UserStore r) => UserId -> ManagedByUpdate -> (Handle updateManagedByH uid (ManagedByUpdate managedBy) = do NoContent <$ lift (liftSem $ UserStore.updateManagedBy uid managedBy) +deletePendingEmailUpdateH :: + ( Member UserStore r, + Member ActivationCodeStore r + ) => + UserId -> + (Handler r) NoContent +deletePendingEmailUpdateH uid = do + mUser <- lift . liftSem $ UserStore.getUser uid + for_ (emailUnvalidated =<< mUser) $ \email -> + lift . liftSem $ do + ActivationCode.deleteActivationCode (mkEmailKey email) + UserStore.deleteEmailUnvalidated uid + pure NoContent + updateRichInfoH :: (Member UserStore r) => UserId -> RichInfoUpdate -> (Handler r) NoContent updateRichInfoH uid rup = NoContent <$ do diff --git a/services/spar/src/Spar/Scim/User.hs b/services/spar/src/Spar/Scim/User.hs index 118e00d2ed2..ad530d8412c 100644 --- a/services/spar/src/Spar/Scim/User.hs +++ b/services/spar/src/Spar/Scim/User.hs @@ -1000,8 +1000,12 @@ synthesizeStoredUser acc veid = writeState oldAccessTimes oldManagedBy oldRichInfo storedUser = do when (isNothing oldAccessTimes) $ ScimUserTimesStore.write storedUser - when (oldManagedBy /= ManagedByScim) $ + when (oldManagedBy /= ManagedByScim) $ do BrigAPIAccess.setManagedBy uid ManagedByScim + -- Invalidate any pending email-address update: a SCIM-managed user's + -- email can only be changed through SCIM, so the pending update token + -- and the unvalidated email must be removed. + BrigAPIAccess.deletePendingEmailUpdate uid let newRichInfo = view ST.sueRichInfo . Scim.extra . Scim.value . Scim.thing $ storedUser when (oldRichInfo /= newRichInfo) $ BrigAPIAccess.setRichInfo uid newRichInfo @@ -1130,6 +1134,8 @@ getUserById midp stiTeam uid = do -- set managed_by when (userManagedBy brigUser /= ManagedByScim) do lift $ BrigAPIAccess.setManagedBy uid ManagedByScim + -- Invalidate any pending email-address update (see comment above). + lift $ BrigAPIAccess.deletePendingEmailUpdate uid -- remove dangling entry from spar.user_v2 table (cassandra) case mbOldVeid of Just oldVeid | ST.veidUref newVeid /= ST.veidUref oldVeid -> do From 141c7f3737f68c06b71eb3f380c9ba84db8ba52a Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Wed, 15 Jul 2026 14:52:23 +0200 Subject: [PATCH 014/113] [fix] claim key packages for ephemeral users (#5339) --- .../ephemeral-user-claim-key-package | 1 + integration/test/Test/MLS/KeyPackage.hs | 21 ++++++++++++++++--- services/brig/src/Brig/API/MLS/KeyPackages.hs | 10 ++++++++- 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 changelog.d/3-bug-fixes/ephemeral-user-claim-key-package diff --git a/changelog.d/3-bug-fixes/ephemeral-user-claim-key-package b/changelog.d/3-bug-fixes/ephemeral-user-claim-key-package new file mode 100644 index 00000000000..d28c848f235 --- /dev/null +++ b/changelog.d/3-bug-fixes/ephemeral-user-claim-key-package @@ -0,0 +1 @@ +Enable claiming key packages for ephemeral users diff --git a/integration/test/Test/MLS/KeyPackage.hs b/integration/test/Test/MLS/KeyPackage.hs index 2ca5d2c4c00..7c58110921f 100644 --- a/integration/test/Test/MLS/KeyPackage.hs +++ b/integration/test/Test/MLS/KeyPackage.hs @@ -5,6 +5,7 @@ module Test.MLS.KeyPackage where import API.Brig +import API.BrigInternal import MLS.Util import SetupHelpers import Testlib.Prelude @@ -27,13 +28,27 @@ testDeleteKeyPackages = do testClaimKeyPackagesUserDeleted :: App () testClaimKeyPackagesUserDeleted = do - (_, _, [alice]) <- createTeam OwnDomain 2 - alice1 <- createMLSClient def alice + (owner, _, [alice]) <- createTeam OwnDomain 2 API.Brig.deleteUser alice >>= assertSuccess - bindResponse (claimKeyPackages def alice1 alice) $ \resp -> do + bindResponse (claimKeyPackages def owner alice) $ \resp -> do + resp.status `shouldMatchInt` 400 + resp.json %. "label" `shouldMatch` "invalid-user" + +testClaimKeyPackagesUserSuspended :: App () +testClaimKeyPackagesUserSuspended = do + (owner, _, [alice]) <- createTeam OwnDomain 2 + API.BrigInternal.setAccountStatus alice "suspended" >>= assertSuccess + bindResponse (claimKeyPackages def owner alice) $ \resp -> do resp.status `shouldMatchInt` 400 resp.json %. "label" `shouldMatch` "invalid-user" +testClaimKeyPackagesEphemeralUser :: App () +testClaimKeyPackagesEphemeralUser = do + user <- randomUser OwnDomain def + tempUser <- ephemeralUser OwnDomain + bindResponse (claimKeyPackages def user tempUser) $ \resp -> do + resp.status `shouldMatchInt` 200 + testKeyPackageMultipleCiphersuites :: App () testKeyPackageMultipleCiphersuites = do let suite = def diff --git a/services/brig/src/Brig/API/MLS/KeyPackages.hs b/services/brig/src/Brig/API/MLS/KeyPackages.hs index 08f1f727caf..7a592321328 100644 --- a/services/brig/src/Brig/API/MLS/KeyPackages.hs +++ b/services/brig/src/Brig/API/MLS/KeyPackages.hs @@ -118,7 +118,15 @@ claimLocalKeyPackages :: claimLocalKeyPackages qusr skipOwn suite qTarget = do let target = tUnqualified qTarget su <- lift (liftSem $ getUser target) >>= maybe (throwE (ClientUserNotFound target)) pure - when (not su.activated || maybe True ((/=) Active) su.status) $ throwE (ClientUserNotFound target) + case (su.activated, su.status) of + (True, Just Active) -> pure () + -- an ephemeral user is identity-less and won't get activated + (_, Just Ephemeral) -> pure () + (False, _) -> throwE $ ClientUserNotFound target + (True, Nothing) -> throwE $ ClientUserNotFound target + (True, Just Deleted) -> throwE $ ClientUserNotFound target + (True, Just Suspended) -> throwE $ ClientUserNotFound target + (True, Just PendingInvitation) -> throwE $ ClientUserNotFound target -- while we do not support federation + MLS together with legalhold, to make sure that -- the remote backend is complicit with our legalhold policies, we disallow anyone -- fetching key packages for users under legalhold From ed4386e368aa35ab0a1d95175a107ca75350022b Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 15 Jul 2026 17:13:02 +0200 Subject: [PATCH 015/113] WPB-26823: make meetings cleanup fully index-driven (#5328) --- changelog.d/5-internal/WPB-26823 | 10 ++++ .../WPB-26823-recurrence-constraint | 4 ++ ...0000-meetings-recurrence-eff-end-index.sql | 27 ++++++++++ ...0-meetings-end-time-nonrecurring-index.sql | 24 +++++++++ ...ings-recurrence-consistency-constraint.sql | 32 ++++++++++++ .../src/Wire/MeetingsStore/Postgres.hs | 50 +++++++++++++++---- .../src/Wire/PostgresMigrations.hs | 7 ++- .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 30 +++++++++++ .../Wire/MockInterpreters/MeetingsStore.hs | 2 +- postgres-schema.sql | 15 ++++++ 10 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 changelog.d/5-internal/WPB-26823 create mode 100644 changelog.d/5-internal/WPB-26823-recurrence-constraint create mode 100644 libs/wire-subsystems/postgres-migrations/20260708090000-meetings-recurrence-eff-end-index.sql create mode 100644 libs/wire-subsystems/postgres-migrations/20260708100000-meetings-end-time-nonrecurring-index.sql create mode 100644 libs/wire-subsystems/postgres-migrations/20260713120000-meetings-recurrence-consistency-constraint.sql diff --git a/changelog.d/5-internal/WPB-26823 b/changelog.d/5-internal/WPB-26823 new file mode 100644 index 00000000000..67e387b6b27 --- /dev/null +++ b/changelog.d/5-internal/WPB-26823 @@ -0,0 +1,10 @@ +Added partial indexes and rewrote the meetings cleanup query so the background +worker stays fully index-backed as the `meetings` table grows: +- `idx_meetings_recurrence_eff_end` on `GREATEST(end_time, recurrence_until)` + for bounded recurring meetings (covers the recurring branches of the list and + cleanup queries). +- `idx_meetings_end_time_nonrecurring` on `end_time` for non-recurring meetings, + so cleanup can find expired non-recurring meetings without scanning + not-yet-expired recurring rows whose original slot is long past. +`getOldMeetingsImpl` now issues one bounded, index-backed query per meeting kind +and merges the two batches. diff --git a/changelog.d/5-internal/WPB-26823-recurrence-constraint b/changelog.d/5-internal/WPB-26823-recurrence-constraint new file mode 100644 index 00000000000..00cdc117968 --- /dev/null +++ b/changelog.d/5-internal/WPB-26823-recurrence-constraint @@ -0,0 +1,4 @@ +Added a `meetings_recurrence_consistency` CHECK constraint so the recurrence +columns can never be left in a partially-set state (frequency is the master +switch; interval is required when set; recurrence_until stays optional for +open-ended recurring meetings). diff --git a/libs/wire-subsystems/postgres-migrations/20260708090000-meetings-recurrence-eff-end-index.sql b/libs/wire-subsystems/postgres-migrations/20260708090000-meetings-recurrence-eff-end-index.sql new file mode 100644 index 00000000000..ff2f6bdd75f --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260708090000-meetings-recurrence-eff-end-index.sql @@ -0,0 +1,27 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- Partial expression index for the recurring-meeting branches of +-- listMeetingsByUserImpl, listMeetingsByConversationImpl and +-- getOldMeetingsImpl, which filter/order on +-- GREATEST(end_time, recurrence_until). Non-recurring meetings keep using +-- idx_meetings_end_time. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_meetings_recurrence_eff_end + ON meetings (GREATEST(end_time, recurrence_until)) + WHERE recurrence_frequency IS NOT NULL + AND recurrence_interval IS NOT NULL + AND recurrence_until IS NOT NULL; diff --git a/libs/wire-subsystems/postgres-migrations/20260708100000-meetings-end-time-nonrecurring-index.sql b/libs/wire-subsystems/postgres-migrations/20260708100000-meetings-end-time-nonrecurring-index.sql new file mode 100644 index 00000000000..555f8ed2674 --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260708100000-meetings-end-time-nonrecurring-index.sql @@ -0,0 +1,24 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- Partial index for the non-recurring branch of getOldMeetingsImpl, so the +-- cleanup worker can find expired non-recurring meetings ordered by end_time +-- without scanning not-yet-expired recurring meetings (which carry an old +-- end_time but a recurrence window still open in the future). +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_meetings_end_time_nonrecurring + ON meetings (end_time) + WHERE recurrence_frequency IS NULL; diff --git a/libs/wire-subsystems/postgres-migrations/20260713120000-meetings-recurrence-consistency-constraint.sql b/libs/wire-subsystems/postgres-migrations/20260713120000-meetings-recurrence-consistency-constraint.sql new file mode 100644 index 00000000000..88447a697dd --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260713120000-meetings-recurrence-consistency-constraint.sql @@ -0,0 +1,32 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- Migration: add CHECK constraint enforcing meetings recurrence consistency. +-- Description: recurrence_frequency is the master switch. NULL => non-recurring +-- (interval and until must be NULL). NOT NULL => recurring, which requires +-- interval NOT NULL; recurrence_until stays optional (open-ended recurring +-- meetings never expire and are intentionally excluded from cleanup). + +ALTER TABLE meetings + ADD CONSTRAINT meetings_recurrence_consistency CHECK ( + (recurrence_frequency IS NULL + AND recurrence_interval IS NULL + AND recurrence_until IS NULL) + OR + (recurrence_frequency IS NOT NULL + AND recurrence_interval IS NOT NULL) + ); diff --git a/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs index a4bb9ddec74..7188a2309f0 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs @@ -25,6 +25,7 @@ module Wire.MeetingsStore.Postgres where import Data.Id +import Data.List qualified as List import Data.Profunctor (dimap) import Data.Range (Range, fromRange) import Data.Time.Clock @@ -404,10 +405,22 @@ getOldMeetingsImpl :: getOldMeetingsImpl cutoffTime batchSize = do runSession session where + n = fromIntegral batchSize :: Int32 session :: Session [StoredMeeting] - session = statement (cutoffTime, fromIntegral batchSize) $ V.toList <$> listStatement - listStatement :: Statement (UTCTime, Int32) (V.Vector StoredMeeting) - listStatement = + session = do + -- Two separate queries so each branch can use its dedicated partial index: + -- * non-recurring -> idx_meetings_end_time_nonrecurring (end_time) + -- * recurring -> idx_meetings_recurrence_eff_end + -- (GREATEST(end_time, recurrence_until)) + -- A single OR query would match neither partial index and force a scan. + -- Results are merged and re-sorted by 'effectiveEndTime' below. + nonRecurring <- statement (cutoffTime, n) nonRecurringOldStatement + recurring <- statement (cutoffTime, n) recurringOldStatement + pure $ + take batchSize $ + List.sortOn effectiveEndTime (V.toList nonRecurring <> V.toList recurring) + nonRecurringOldStatement :: Statement (UTCTime, Int32) (V.Vector StoredMeeting) + nonRecurringOldStatement = refineResult (traverse (postgresUnmarshall @StoredMeetingTuple @StoredMeeting)) $ [vectorStatement| @@ -418,10 +431,27 @@ getOldMeetingsImpl cutoffTime batchSize = do conversation_id :: uuid, invited_emails :: text[], trial :: boolean, created_at :: timestamptz, updated_at :: timestamptz FROM meetings - WHERE (recurrence_frequency IS NULL AND end_time < ($1 :: timestamptz)) - OR (recurrence_frequency IS NOT NULL AND recurrence_interval IS NOT NULL - AND recurrence_until IS NOT NULL - AND GREATEST(end_time, recurrence_until) < ($1 :: timestamptz)) - ORDER BY end_time ASC - LIMIT ($2 :: int4) - |] + WHERE recurrence_frequency IS NULL + AND end_time < ($1 :: timestamptz) + ORDER BY end_time ASC + LIMIT ($2 :: int4) + |] + recurringOldStatement :: Statement (UTCTime, Int32) (V.Vector StoredMeeting) + recurringOldStatement = + refineResult + (traverse (postgresUnmarshall @StoredMeetingTuple @StoredMeeting)) + $ [vectorStatement| + SELECT + id :: uuid, title :: text, creator :: uuid, + start_time :: timestamptz, end_time :: timestamptz, + recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?, + conversation_id :: uuid, invited_emails :: text[], trial :: boolean, + created_at :: timestamptz, updated_at :: timestamptz + FROM meetings + WHERE recurrence_frequency IS NOT NULL + AND recurrence_interval IS NOT NULL + AND recurrence_until IS NOT NULL + AND GREATEST(end_time, recurrence_until) < ($1 :: timestamptz) + ORDER BY GREATEST(end_time, recurrence_until) ASC + LIMIT ($2 :: int4) + |] diff --git a/libs/wire-subsystems/src/Wire/PostgresMigrations.hs b/libs/wire-subsystems/src/Wire/PostgresMigrations.hs index f3372235d28..cc0ccefa378 100644 --- a/libs/wire-subsystems/src/Wire/PostgresMigrations.hs +++ b/libs/wire-subsystems/src/Wire/PostgresMigrations.hs @@ -42,7 +42,12 @@ allMigrations = map (\(name, contentBS) -> MigrationScript name (Text.decodeUtf8 -- | Scripts which cannot be run in a transaction nonTransactionMigrations :: Set ScriptName -nonTransactionMigrations = Set.fromList ["20260428072649-create-conv-parent-index.sql"] +nonTransactionMigrations = + Set.fromList + [ "20260428072649-create-conv-parent-index.sql", + "20260708090000-meetings-recurrence-eff-end-index.sql", + "20260708100000-meetings-end-time-nonrecurring-index.sql" + ] data PostgresMigrationError = PostgresMigrationError MigrationError diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index 3a0e9b7c2ba..dccc69ce640 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -1030,6 +1030,16 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do interval = 1, until = Nothing } + meetingAt endOffset r = + API.NewMeeting + { title = fromJust $ checked "Meeting", + startTime = addUTCTime (endOffset - 3600) now, + endTime = addUTCTime endOffset now, + recurrence = r, + invitedEmails = [] + } + recurUntil t = + Just (API.Recurrence {freq = API.Daily, interval = 1, until = Just t}) it "getMeeting returns a recurring meeting whose slot passed but window is open" $ do result <- @@ -1134,6 +1144,26 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do deleted `shouldBe` 0 remainingId `shouldBe` Just meetingId + it "cleanupOldMeetings deletes in effectiveEndTime order, not endTime order" $ do + result <- + runTestStack now gen Map.empty teamConfig $ do + -- endTime now+8000, no recurrence -> effectiveEndTime now+8000 (earliest) + plain <- createMeeting zUser (meetingAt 8000 Nothing) + -- endTime now+4000, until now+10000 -> effectiveEndTime now+10000 (later) + recur <- createMeeting zUser (meetingAt 4000 (recurUntil (addUTCTime 10000 now))) + _deleted <- cleanupOldMeetings (addUTCTime 11000 now) 1 + plainRemains <- isJust <$> getMeeting zUser plain.meeting.id + recurRemains <- isJust <$> getMeeting zUser recur.meeting.id + pure (plainRemains, recurRemains) + case result of + Left err -> fail $ "Error: " <> show err + Right (plainRemains, recurRemains) -> do + -- plain has the earlier effectiveEndTime, so it is deleted first; + -- recur survives. With the old endTime sort, recur (endTime now+4000) + -- would be deleted first instead. + plainRemains `shouldBe` False + recurRemains `shouldBe` True + prop "aliveness follows effectiveEndTime across get/list/cleanup" $ \(recurrence :: Maybe API.Recurrence) (advance :: NonNegative Int) -> let startTime = addUTCTime 3600 now diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs index fde676442c4..6f2b59d55d9 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs @@ -120,6 +120,6 @@ inMemoryMeetingsStoreInterpreter = interpret $ \case GetOldMeetings cutoffTime batchSize -> gets $ take batchSize - . List.sortOn (.endTime) + . List.sortOn effectiveEndTime . filter (\sm -> maybe False (< cutoffTime) (effectiveEndTime sm)) . Map.elems diff --git a/postgres-schema.sql b/postgres-schema.sql index c60938bceb0..948f1d62594 100644 --- a/postgres-schema.sql +++ b/postgres-schema.sql @@ -303,6 +303,7 @@ CREATE TABLE public.meetings ( trial boolean DEFAULT false NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT meetings_recurrence_consistency CHECK ((((recurrence_frequency IS NULL) AND (recurrence_interval IS NULL) AND (recurrence_until IS NULL)) OR ((recurrence_frequency IS NOT NULL) AND (recurrence_interval IS NOT NULL)))), CONSTRAINT meetings_title_length CHECK ((length(title) <= 256)), CONSTRAINT meetings_title_not_empty CHECK ((length(TRIM(BOTH FROM title)) > 0)), CONSTRAINT meetings_valid_time_range CHECK ((end_time > start_time)) @@ -784,6 +785,20 @@ CREATE INDEX idx_meetings_creator ON public.meetings USING btree (creator); CREATE INDEX idx_meetings_end_time ON public.meetings USING btree (end_time); +-- +-- Name: idx_meetings_end_time_nonrecurring; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX idx_meetings_end_time_nonrecurring ON public.meetings USING btree (end_time) WHERE (recurrence_frequency IS NULL); + + +-- +-- Name: idx_meetings_recurrence_eff_end; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX idx_meetings_recurrence_eff_end ON public.meetings USING btree (GREATEST(end_time, recurrence_until)) WHERE ((recurrence_frequency IS NOT NULL) AND (recurrence_interval IS NOT NULL) AND (recurrence_until IS NOT NULL)); + + -- -- Name: idx_meetings_start_time; Type: INDEX; Schema: public; Owner: wire-server -- From 44e7580fcc1d49e794be2252f11705c58d2e7244 Mon Sep 17 00:00:00 2001 From: Sven Tennie Date: Thu, 16 Jul 2026 08:44:20 +0200 Subject: [PATCH 016/113] Reorder SSO nginx locations to enforce correct rate limiting (#5341) `/sso/get-by-email` needs to appear before `/sso` in nginx's config, because regex locations are matched in order (first-match), not by specificity. In previous order `/sso` caught before `/sso/get-by-email` applied the specific 5r/m rate limit, leaving it on the generic 50r/s limit. --- .../3-bug-fixes/fix-sso-get-by-email-rate-limiting | 5 +++++ charts/nginz/values.yaml | 12 ++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 changelog.d/3-bug-fixes/fix-sso-get-by-email-rate-limiting diff --git a/changelog.d/3-bug-fixes/fix-sso-get-by-email-rate-limiting b/changelog.d/3-bug-fixes/fix-sso-get-by-email-rate-limiting new file mode 100644 index 00000000000..a087364dee6 --- /dev/null +++ b/changelog.d/3-bug-fixes/fix-sso-get-by-email-rate-limiting @@ -0,0 +1,5 @@ +Reorder SSO nginx locations to enforce correct rate limiting: +`/sso/get-by-email` needs to appear before `/sso` in nginx's config, because +regex locations are matched in order (first-match), not by specificity. In +previous order `/sso` caught before `/sso/get-by-email` applied the specific +5r/m rate limit, leaving it on the generic 50r/s limit. diff --git a/charts/nginz/values.yaml b/charts/nginz/values.yaml index 10140fa1f34..ae9d3c84702 100644 --- a/charts/nginz/values.yaml +++ b/charts/nginz/values.yaml @@ -816,6 +816,12 @@ nginx_conf: allow_credentials: true specific_user_rate_limit: reqs_per_addr_sso specific_user_rate_limit_burst: "10" + - path: /sso/get-by-email$ + envs: + - all + disable_zauth: true + specific_user_rate_limit: reqs_per_addr_sso_get_by_email + specific_user_rate_limit_burst: "15" - path: /sso envs: - all @@ -829,12 +835,6 @@ nginx_conf: allow_credentials: true specific_user_rate_limit: reqs_per_addr_sso specific_user_rate_limit_burst: "10" - - path: /sso/get-by-email$ - envs: - - all - disable_zauth: true - specific_user_rate_limit: reqs_per_addr_sso_get_by_email - specific_user_rate_limit_burst: "15" - path: /scim envs: - all From b40a9a3c1e3cda4a6c3fdac0844802f4426e544a Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Thu, 16 Jul 2026 10:14:19 +0200 Subject: [PATCH 017/113] WPB-26705: add meeting.create/update/delete lifecycle events (#5330) --------- Co-authored-by: Leif Battermann --- .../2-features/wpb-26705-meeting-events.md | 7 ++ integration/test/Notifications.hs | 12 ++ integration/test/Test/Meetings.hs | 14 ++- .../src/Wire/API/Event/Conversation.hs | 26 +++- .../test/unit/Test/Wire/API/Conversation.hs | 3 + .../src/Wire/MeetingsSubsystem/Interpreter.hs | 50 ++++++++ .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 118 ++++++++++++++++++ 7 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 changelog.d/2-features/wpb-26705-meeting-events.md diff --git a/changelog.d/2-features/wpb-26705-meeting-events.md b/changelog.d/2-features/wpb-26705-meeting-events.md new file mode 100644 index 00000000000..6eff9dce988 --- /dev/null +++ b/changelog.d/2-features/wpb-26705-meeting-events.md @@ -0,0 +1,7 @@ +* Added meeting lifecycle events: `meeting.create`, `meeting.update`, and + `meeting.delete` (WPB-26705). These websocket notifications are pushed to all + local members of the meeting's conversation on every successful create, update, + and delete operation. The events use the standard conversation event envelope: + each payload contains the event `type`, the meeting's qualified ID in the `data` + field, the `qualified_conversation`, `qualified_from`, `via`, `time`, and + optional `team`. diff --git a/integration/test/Notifications.hs b/integration/test/Notifications.hs index 67281da2d6e..9b1aa274a0f 100644 --- a/integration/test/Notifications.hs +++ b/integration/test/Notifications.hs @@ -196,6 +196,18 @@ isConvCreateMeetingNotif :: (HasCallStack, MakesValue a) => a -> App Bool isConvCreateMeetingNotif n = fieldEquals n "payload.0.type" "conversation.create-meeting" +isMeetingCreateNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isMeetingCreateNotif n = + fieldEquals n "payload.0.type" "meeting.create" + +isMeetingUpdateNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isMeetingUpdateNotif n = + fieldEquals n "payload.0.type" "meeting.update" + +isMeetingDeleteNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isMeetingDeleteNotif n = + fieldEquals n "payload.0.type" "meeting.delete" + -- | like 'isConvCreateNotif' but excludes self conversations isConvCreateNotifNotSelf :: (HasCallStack, MakesValue a) => a -> App Bool isConvCreateNotifNotSelf n = diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs index 1feb0c0b898..5270e7730b3 100644 --- a/integration/test/Test/Meetings.hs +++ b/integration/test/Test/Meetings.hs @@ -10,7 +10,7 @@ import qualified Data.Text.Encoding as Text import Data.Time.Clock import qualified Data.Time.Format as Time import MLS.Util -import Notifications (isConvCreateMeetingNotif) +import Notifications (isConvCreateMeetingNotif, isMeetingCreateNotif, isMeetingDeleteNotif, isMeetingUpdateNotif) import SetupHelpers import System.Timeout (timeout) import Testlib.Prelude @@ -31,6 +31,7 @@ testMeetingCreate = do resp <- postMeetings owner newMeeting assertSuccess resp void $ awaitMatch isConvCreateMeetingNotif ws + void $ awaitMatch isMeetingCreateNotif ws getJSON 201 resp meeting %. "title" `shouldMatch` ("Team Standup" :: String) @@ -213,8 +214,11 @@ testMeetingRecurrence = do "recurrence" .= updatedRecurrence ] - r2 <- putMeeting owner domain meetingId updatedMeeting - assertSuccess r2 + r2 <- withWebSocket owner $ \ws -> do + resp <- putMeeting owner domain meetingId updatedMeeting + assertSuccess resp + void $ awaitMatch isMeetingUpdateNotif ws + pure resp updated <- getJSON 200 r2 updated %. "title" `shouldMatch` ("Updated Standup" :: String) @@ -454,7 +458,9 @@ testMeetingDelete = do assertSuccess r1 meeting <- getJSON 201 r1 (meetingId, domain) <- getMeetingIdAndDomain meeting - deleteMeeting owner domain meetingId >>= assertStatus 200 + withWebSocket owner $ \ws -> do + deleteMeeting owner domain meetingId >>= assertStatus 200 + void $ awaitMatch isMeetingDeleteNotif ws getMeeting owner domain meetingId >>= assertStatus 404 testMeetingDeleteNotFound :: (HasCallStack) => App () diff --git a/libs/wire-api/src/Wire/API/Event/Conversation.hs b/libs/wire-api/src/Wire/API/Event/Conversation.hs index a75477d2ade..5ae6a3a5b89 100644 --- a/libs/wire-api/src/Wire/API/Event/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Event/Conversation.hs @@ -58,6 +58,9 @@ module Wire.API.Event.Conversation _EdMLSMessage, _EdMLSWelcome, _EdAddPermissionUpdate, + _EdMeetingCreate, + _EdMeetingUpdate, + _EdMeetingDelete, -- * Event data helpers SimpleMember (..), @@ -194,6 +197,9 @@ data EventType | ProtocolUpdate | AddPermissionUpdate | ConvHistoryUpdate + | MeetingCreate + | MeetingUpdate + | MeetingDelete deriving stock (Eq, Show, Generic, Enum, Bounded, Ord) deriving (Arbitrary) via (GenericUniform EventType) deriving (FromJSON, ToJSON, S.ToSchema) via Schema EventType @@ -222,7 +228,10 @@ instance ToSchema EventType where element "conversation.mls-welcome" MLSWelcome, element "conversation.protocol-update" ProtocolUpdate, element "conversation.add-permission-update" AddPermissionUpdate, - element "conversation.history-update" ConvHistoryUpdate + element "conversation.history-update" ConvHistoryUpdate, + element "meeting.create" MeetingCreate, + element "meeting.update" MeetingUpdate, + element "meeting.delete" MeetingDelete ] data EventData @@ -247,6 +256,9 @@ data EventData | EdProtocolUpdate P.ProtocolTag | EdAddPermissionUpdate Conv.AddPermissionUpdate | EdConvHistoryUpdate History + | EdMeetingCreate (Qualified MeetingId) + | EdMeetingUpdate (Qualified MeetingId) + | EdMeetingDelete (Qualified MeetingId) deriving stock (Eq, Show, Generic) genEventData :: EventType -> QC.Gen EventData @@ -272,6 +284,9 @@ genEventData = \case ProtocolUpdate -> EdProtocolUpdate <$> arbitrary AddPermissionUpdate -> EdAddPermissionUpdate <$> arbitrary ConvHistoryUpdate -> EdConvHistoryUpdate <$> arbitrary + MeetingCreate -> EdMeetingCreate <$> arbitrary + MeetingUpdate -> EdMeetingUpdate <$> arbitrary + MeetingDelete -> EdMeetingDelete <$> arbitrary eventDataType :: EventData -> EventType eventDataType (EdMembersJoin _) = MemberJoin @@ -295,6 +310,9 @@ eventDataType (EdConvReset _) = ConvReset eventDataType (EdProtocolUpdate _) = ProtocolUpdate eventDataType (EdAddPermissionUpdate _) = AddPermissionUpdate eventDataType (EdConvHistoryUpdate _) = ConvHistoryUpdate +eventDataType (EdMeetingCreate _) = MeetingCreate +eventDataType (EdMeetingUpdate _) = MeetingUpdate +eventDataType (EdMeetingDelete _) = MeetingDelete createConversationEventData :: OwnConversation GroupConvType -> EventData @@ -326,6 +344,9 @@ isCellsConversationEvent eventType = ProtocolUpdate -> False AddPermissionUpdate -> False ConvHistoryUpdate -> False + MeetingCreate -> False + MeetingUpdate -> False + MeetingDelete -> False -------------------------------------------------------------------------------- -- Event data helpers @@ -529,6 +550,9 @@ taggedEventDataSchema = ProtocolUpdate -> tag _EdProtocolUpdate (unnamed (unProtocolUpdate <$> P.ProtocolUpdate .= schema)) AddPermissionUpdate -> tag _EdAddPermissionUpdate (unnamed schema) ConvHistoryUpdate -> tag _EdConvHistoryUpdate (unnamed schema) + MeetingCreate -> tag _EdMeetingCreate (unnamed schema) + MeetingUpdate -> tag _EdMeetingUpdate (unnamed schema) + MeetingDelete -> tag _EdMeetingDelete (unnamed schema) memberLeaveSchema :: ValueSchema NamedSwaggerDoc (EdMemberLeftReason, QualifiedUserIdList) memberLeaveSchema = diff --git a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs index aa9527a3e69..4c9f6fbe679 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs @@ -85,6 +85,9 @@ testIsCellsConversationEvent = OtrMessageAdd -> isCellsConversationEvent e === False ProtocolUpdate -> isCellsConversationEvent e === False Typing -> isCellsConversationEvent e === False + MeetingCreate -> isCellsConversationEvent e === False + MeetingUpdate -> isCellsConversationEvent e === False + MeetingDelete -> isCellsConversationEvent e === False -------------------------------------------------------------------------------- -- Legacy conversion tests diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs index c3361d1add5..4b5d62bab2c 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs @@ -27,6 +27,7 @@ import Data.ByteString.Conversion (toByteString') import Data.Default (def) import Data.Domain (Domain) import Data.Id +import Data.Json.Util (toJSONObject) import Data.Map qualified as Map import Data.Qualified (Local, Qualified (..), inputQualifyLocal, qualifyAs, tDomain, tUnqualified) import Data.Range (Range, unsafeRange) @@ -41,7 +42,9 @@ import Polysemy.TinyLog qualified as TinyLog import System.Logger qualified as Log import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Role (roleNameWireAdmin) +import Wire.API.Event.Conversation qualified as ConvEvent import Wire.API.Meeting qualified as API +import Wire.API.Push.V2 qualified as PushV2 import Wire.API.Routes.MultiTablePaging qualified as MultiTablePaging import Wire.API.Team.Feature (FeatureStatus (..), LockableFeature (..), MeetingsConfig) import Wire.API.User (BaseProtocolTag (BaseProtocolMLSTag), EmailAddress) @@ -50,6 +53,7 @@ import Wire.ConversationSubsystem qualified as ConversationSubsystem import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem, getFeatureForTeam) import Wire.MeetingsStore qualified as Store import Wire.MeetingsSubsystem +import Wire.NotificationSubsystem import Wire.Sem.Now (Now) import Wire.Sem.Now qualified as Now import Wire.StoredConversation @@ -93,6 +97,7 @@ interpretMeetingsSubsystem :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member NotificationSubsystem r, Member Now r, Member TinyLog r, Member (Error MeetingError) r, @@ -125,6 +130,7 @@ createMeetingImpl :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member NotificationSubsystem r, Member Now r, Member (Error MeetingError) r ) => @@ -186,6 +192,9 @@ createMeetingImpl zUser newMeeting = do newMeeting.invitedEmails trial + let qMeetingId = Qualified storedMeeting.id (tDomain zUser) + pushMeetingEvent zUser Nothing storedConv.localMembers (Qualified storedConv.id_ (tDomain zUser)) conversationTeamId (ConvEvent.EdMeetingCreate qMeetingId) + pure $ storedMeetingToMeetingWithConversation zUser storedConv storedMeeting updateMeetingImpl :: @@ -193,6 +202,7 @@ updateMeetingImpl :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member NotificationSubsystem r, Member TinyLog r, Member (Error MeetingError) r, Member Now r @@ -233,6 +243,7 @@ updateMeetingImpl zUser meetingId update validityPeriod = do update.endTime update.recurrence conv <- MaybeT $ getMeetingConversationOrFail meetingId updatedMeeting.conversationId + lift $ pushMeetingEvent zUser Nothing conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId (ConvEvent.EdMeetingUpdate meetingId) pure $ storedMeetingToMeetingWithConversation zUser conv updatedMeeting deleteMeetingImpl :: @@ -240,6 +251,7 @@ deleteMeetingImpl :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member NotificationSubsystem r, Member TinyLog r, Member (Error MeetingError) r, Member Now r @@ -268,6 +280,7 @@ deleteMeetingImpl zUser connId meetingId validityPeriod = do void $ ConversationSubsystem.deleteLocalConversation zUser connId lConvId lift $ Store.deleteMeeting (qUnqualified meetingId) + lift $ pushMeetingEvent zUser (Just connId) conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId (ConvEvent.EdMeetingDelete meetingId) pure $ isJust result getMeetingImpl :: @@ -324,6 +337,43 @@ getMeetingConversationOrFail meetingId convId = do . Log.field "meetingId" (toByteString' (qUnqualified meetingId)) pure Nothing +-- | Push a meeting lifecycle event to all local members of the meeting's +-- conversation via the 'NotificationSubsystem'. Meetings are not federated, so +-- only local members are notified. +pushMeetingEvent :: + ( Member NotificationSubsystem r, + Member Now r + ) => + Local UserId -> + Maybe ConnId -> + [LocalMember] -> + Qualified ConvId -> + Maybe TeamId -> + ConvEvent.EventData -> + Sem r () +pushMeetingEvent lUser conn members qConvId mTeamId edata = do + now <- Now.get + let evt = + ConvEvent.Event + { evtConv = qConvId, + evtSubConv = Nothing, + evtFrom = + ConvEvent.EventFromUser + (Qualified (tUnqualified lUser) (tDomain lUser)), + evtTime = now, + evtTeam = mTeamId, + evtData = edata + } + pushNotifications + [ def + { origin = Just (tUnqualified lUser), + json = toJSONObject evt, + recipients = map localMemberToRecipient members, + route = PushV2.RouteDirect, + conn + } + ] + -- Helper function to convert StoredMeeting to API.Meeting storedMeetingToMeeting :: Domain -> Store.StoredMeeting -> API.Meeting storedMeetingToMeeting domain sm = diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index dccc69ce640..a177e9a8b39 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -17,6 +17,7 @@ module Wire.MeetingsSubsystem.InterpreterSpec (spec) where +import Data.Aeson (Result (..), Value (Object), fromJSON) import Data.ByteString.Char8 qualified as C import Data.Default (def) import Data.Domain (Domain (..)) @@ -43,6 +44,7 @@ import Text.Email.Parser (unsafeEmailAddress) import Wire.API.Conversation (Access (InviteAccess, PrivateAccess), Conversation (metadata, qualifiedId), ConversationMetadata (cnvmAccess)) import Wire.API.Error (ErrorS) import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound)) +import Wire.API.Event.Conversation qualified as ConvEvent import Wire.API.Meeting qualified as API import Wire.API.Team.Feature import Wire.API.Team.Member (TeamMember, mkTeamMember) @@ -54,6 +56,7 @@ import Wire.MeetingsStore qualified as Store import Wire.MeetingsSubsystem import Wire.MeetingsSubsystem.Interpreter import Wire.MockInterpreters +import Wire.NotificationSubsystem (NotificationSubsystem, Push (..)) import Wire.Sem.Logger.TinyLog (discardTinyLogs) import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) @@ -67,11 +70,13 @@ type TestStack = ConversationSubsystem, TeamSubsystem, FeaturesConfigSubsystem, + NotificationSubsystem, TinyLog, Error MeetingError, State (Map MeetingId Store.StoredMeeting), State (Map ConvId StoredConversation), State (Map ConvId (Set UserId)), + State [Push], GalleyAPIAccess, Now, State UTCTime, @@ -117,17 +122,35 @@ runTestStack now gen teams configs = . evalState now . interpretNowAsState . miniGalleyAPIAccess teams configs + . evalState ([] :: [Push]) . evalState Map.empty . evalState Map.empty . evalState Map.empty . runError @MeetingError . discardTinyLogs + . inMemoryNotificationSubsystemInterpreter . interpretFeaturesConfigSubsystemPure configs . interpretTeamSubsystemToGalleyAPI . inMemoryConversationSubsystemInterpreter . inMemoryMeetingsStoreInterpreter . interpretMeetingsSubsystem 3600 +-- | Decode all 'Push' payloads that are conversation events carrying meeting +-- lifecycle data. +extractMeetingEvents :: [Push] -> [ConvEvent.Event] +extractMeetingEvents pushes = + [ e + | push <- pushes, + Success e <- [fromJSON (Object push.json)], + isMeetingEvent e + ] + where + isMeetingEvent e = case e.evtData of + ConvEvent.EdMeetingCreate _ -> True + ConvEvent.EdMeetingUpdate _ -> True + ConvEvent.EdMeetingDelete _ -> True + _ -> False + spec :: Spec spec = describe "MeetingsSubsystem.Interpreter" $ do it "creates a meeting and can retrieve it" $ do @@ -1333,6 +1356,101 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do result2 `shouldBe` Left MeetingsFeatureDisabled + describe "meeting events" $ do + let now = UTCTime (fromGregorian 2026 1 1) 0 + gen = mkStdGen 42 + uid1 = Id $ read "00000000-0000-0000-0000-000000000001" + uid2 = Id $ read "00000000-0000-0000-0000-000000000002" + zUser1 = toLocalUnsafe (Domain "wire.com") uid1 + zUser2 = toLocalUnsafe (Domain "wire.com") uid2 + teamId = Id $ read "00000000-0000-0000-0000-000000000100" + teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled + teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled + teamConfig = + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) def + newMeeting = + API.NewMeeting + { title = fromJust $ checked "Event Test Meeting", + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + recurrence = Nothing, + invitedEmails = [] + } + + it "emits a meeting.create event on successful create" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do + meeting <- createMeeting zUser1 newMeeting + pushes <- get @[Push] + pure (meeting, pushes) + + case result of + Left err -> fail $ "Error: " <> show err + Right (meeting, pushes) -> do + let events = extractMeetingEvents pushes + length events `shouldBe` 1 + case (head events).evtData of + ConvEvent.EdMeetingCreate mid -> mid `shouldBe` meeting.meeting.id + other -> fail $ "expected EdMeetingCreate, got " <> show other + + it "emits a meeting.update event on successful update" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do + meeting <- createMeeting zUser1 newMeeting + put @[Push] [] + _ <- updateMeeting zUser1 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) + get @[Push] + + case result of + Left err -> fail $ "Error: " <> show err + Right pushes -> do + let events = extractMeetingEvents pushes + length events `shouldBe` 1 + case (head events).evtData of + ConvEvent.EdMeetingUpdate _ -> pure () + other -> fail $ "expected EdMeetingUpdate, got " <> show other + + it "emits a meeting.delete event on successful delete" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do + meeting <- createMeeting zUser1 newMeeting + put @[Push] [] + _ <- deleteMeeting zUser1 (ConnId "test-conn") meeting.meeting.id + get @[Push] + + case result of + Left err -> fail $ "Error: " <> show err + Right pushes -> do + let events = extractMeetingEvents pushes + length events `shouldBe` 1 + case (head events).evtData of + ConvEvent.EdMeetingDelete _ -> pure () + other -> fail $ "expected EdMeetingDelete, got " <> show other + + it "does not emit an event when updateMeeting fails (non-creator)" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do + meeting <- createMeeting zUser1 newMeeting + put @[Push] [] + _ <- updateMeeting zUser2 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Hijack")) Nothing) + get @[Push] + + case result of + Left err -> fail $ "Error: " <> show err + Right pushes -> extractMeetingEvents pushes `shouldBe` [] + + it "does not emit an event when deleteMeeting fails (non-creator)" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do + meeting <- createMeeting zUser1 newMeeting + put @[Push] [] + _ <- deleteMeeting zUser2 (ConnId "test-conn") meeting.meeting.id + get @[Push] + + case result of + Left err -> fail $ "Error: " <> show err + Right pushes -> extractMeetingEvents pushes `shouldBe` [] + -- | Synchronize with 'Wire.MeetingsSubsystem.Interpreter.startTimeTolerance' expectedStartTimeTolerance :: NominalDiffTime expectedStartTimeTolerance = 60 From 632be415979f0bc13d29f29c0877d11cfa10459c Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 17 Jul 2026 15:35:26 +0200 Subject: [PATCH 018/113] [WPB-27227] Include collaborating apps (aka external apps) in "GET /teams/:tid/apps". (#5343) * Drive-by improvement: move access control from brig to wire-subsystems. --- ...a-external-apps_-in-_get-_teams__tid_apps_ | 1 + .../wire-subsystems/src/Wire/UserSubsystem.hs | 2 +- .../src/Wire/UserSubsystem/Interpreter.hs | 32 ++++++++--- .../Wire/MockInterpreters/UserSubsystem.hs | 2 +- .../Wire/UserSubsystem/InterpreterSpec.hs | 57 +++++++++++++++++++ services/brig/src/Brig/API/Public.hs | 7 +-- 6 files changed, 85 insertions(+), 16 deletions(-) create mode 100644 changelog.d/3-bug-fixes/WPB-27227-include-collaborating-apps-_aka-external-apps_-in-_get-_teams__tid_apps_ diff --git a/changelog.d/3-bug-fixes/WPB-27227-include-collaborating-apps-_aka-external-apps_-in-_get-_teams__tid_apps_ b/changelog.d/3-bug-fixes/WPB-27227-include-collaborating-apps-_aka-external-apps_-in-_get-_teams__tid_apps_ new file mode 100644 index 00000000000..0d49465fecc --- /dev/null +++ b/changelog.d/3-bug-fixes/WPB-27227-include-collaborating-apps-_aka-external-apps_-in-_get-_teams__tid_apps_ @@ -0,0 +1 @@ +Include collaborating apps (aka external apps) in "GET /teams/:tid/apps". ([drive-by] move access control for UserSubsystem.GetLocalAppProfiles from brig into wire-subsystems.) diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem.hs b/libs/wire-subsystems/src/Wire/UserSubsystem.hs index 3980bbf6b8f..37358d294da 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem.hs @@ -130,7 +130,7 @@ data UserSubsystem m a where -- | Sometimes we don't have any identity of a requesting user, and local profiles are public. GetLocalUserProfiles :: Local [UserId] -> UserSubsystem m [UserProfile] -- | Get profiles for all app users in a team, touching only the apps table (efficient). - GetLocalAppProfiles :: Local TeamId -> UserSubsystem m [UserProfile] + GetLocalAppProfiles :: Local UserId -> TeamId -> UserSubsystem m [UserProfile] -- | Get the union of all user accounts matching the `GetBy` argument *and* having a non-empty UserIdentity. GetAccountsBy :: Local GetBy -> UserSubsystem m [User] -- | Get user accounts matching the `[EmailAddress]` argument (accounts with missing diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index 42ec197d844..018d9d931b9 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -59,6 +59,7 @@ import Wire.API.Federation.API.Brig qualified as FedBrig import Wire.API.Federation.Error import Wire.API.Routes.FederationDomainConfig import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti (TeamStatus (..)) +import Wire.API.Team.Collaborator import Wire.API.Team.Export import Wire.API.Team.Feature import Wire.API.Team.Member @@ -96,6 +97,7 @@ import Wire.Sem.Metrics qualified as Metrics import Wire.Sem.Now (Now) import Wire.Sem.Now qualified as Now import Wire.StoredUser +import Wire.TeamCollaboratorsSubsystem import Wire.TeamSubsystem import Wire.UserGroupStore (UserGroupStore, getUserGroupIdsForUsers) import Wire.UserKeyStore @@ -110,7 +112,8 @@ import Wire.UserSubsystem.UserSubsystemConfig import Witherable (wither) runUserSubsystem :: - ( Member AppStore r, + ( Member TeamCollaboratorsSubsystem r, + Member AppStore r, Member UserStore r, Member UserKeyStore r, Member GalleyAPIAccess r, @@ -148,8 +151,8 @@ runUserSubsystem authInterpreter appInterpreter clientInterpreter = getUserProfilesImpl self others GetLocalUserProfiles others -> getLocalUserProfilesImpl others - GetLocalAppProfiles ltid -> - getLocalAppProfilesOnlyImpl ltid + GetLocalAppProfiles self ltid -> + getLocalAppProfilesImpl self ltid GetAccountsBy getBy -> getAccountsByImpl getBy GetAccountsByEmailNoFilter emails -> @@ -366,22 +369,30 @@ getLocalUserProfilesImpl :: Sem r [UserProfile] getLocalUserProfilesImpl = getUserProfilesLocalPart Nothing -getLocalAppProfilesOnlyImpl :: +getLocalAppProfilesImpl :: forall r any. ( Member AppStore r, Member UserStore r, Member (Input UserSubsystemConfig) r, Member DeleteQueue r, + Member (Error UserSubsystemError) r, Member Now r, Member (Concurrency Unsafe) r, Member (Input (Local any)) r, Member AppSubsystem r, + Member TeamCollaboratorsSubsystem r, Member TeamSubsystem r ) => - Local TeamId -> + Local UserId -> + TeamId -> Sem r [UserProfile] -getLocalAppProfilesOnlyImpl ltid = do - apps <- AppStore.getApps (tUnqualified ltid) +getLocalAppProfilesImpl self tid = do + UserStore.getUserTeam (tUnqualified self) >>= \requestingUserTeam -> + unless (requestingUserTeam == Just tid) $ + throw UserSubsystemProfileNotFound + + let ltid = qualifyAs self tid + apps :: [AppStore.StoredApp] <- AppStore.getApps tid profiles <- getUserProfilesLocalPart Nothing (ltid $> map (.id) apps) let appsMap :: Map UserId AppStore.StoredApp appsMap = Map.fromList ((\app -> (app.id, app)) <$> apps) @@ -393,7 +404,12 @@ getLocalAppProfilesOnlyImpl ltid = do Just app -> profile {profileApp = Just (storedAppToAppInfo app)} Nothing -> profile - pure (injectPreloadedApp <$> profiles) + collaboratingApps :: [UserProfile] <- do + allIds <- (.gUser) <$$> getAllTeamCollaborators self tid + allProfiles <- getUserProfilesLocalPart (Just self) (qualifyAs self allIds) + pure (filter (isJust . (.profileApp)) allProfiles) + + pure ((injectPreloadedApp <$> profiles) <> collaboratingApps) getUserProfilesFromDomain :: ( Member (Error FederationError) r, diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs index c5feaeae032..2904be15ef0 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs @@ -68,7 +68,7 @@ inMemoryUserSubsystemInterpreter = GetLocalUserProfiles luids -> toProfile . mkUserFromStored testDomain testLocale <$$> UserStore.getUsers (tUnqualified luids) - GetLocalAppProfiles _ -> + GetLocalAppProfiles _ _ -> error "GetLocalAppProfiles: implement on demand (userSubsystemInterpreter)" GetAccountsBy (tUnqualified -> GetBy NoPendingInvitations True True uids []) -> mkUserFromStored testDomain testLocale <$$> UserStore.getUsers uids diff --git a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs index 9f3eb8da2c9..6fd35962c64 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs @@ -59,6 +59,7 @@ import Wire.API.User hiding (DeleteUser) import Wire.API.User.IdentityProvider (IdPList (..), team) import Wire.API.User.Search import Wire.API.UserEvent +import Wire.AppStore qualified as AppStore import Wire.AppSubsystem import Wire.AuthenticationSubsystem.Error import Wire.ClientSubsystem.Error (ClientError) @@ -1142,3 +1143,59 @@ spec = describe "UserSubsystem.Interpreter" do contactType = fromMaybe UserTypeRegular searchee.userType } pure $ result.searchResults === [expectedContact | fromMaybe True searchee.searchable] + + describe "getLocalAppProfiles" $ do + prop "includes apps that are collaborators from other teams" $ + \(NotPendingStoredUser appUser_) + (NotPendingStoredUser ownerA_) + (NotPendingStoredUser ownerB_) + (teamAId :: TeamId) + (teamBId :: TeamId) + config -> + teamAId /= teamBId ==> + let localDomain = Domain "localdomain" + ownerA = ownerA_ {teamId = Just teamAId} :: StoredUser + ownerB = ownerB_ {teamId = Just teamBId} :: StoredUser + teamAOwnerId = toLocalUnsafe localDomain ownerA.id + teamBOwnerId = toLocalUnsafe localDomain ownerB.id + appUser = + appUser_ {userType = Just UserTypeApp, teamId = Just teamBId} :: StoredUser + storedApp = + AppStore.StoredApp + { id = appUser.id, + teamId = teamBId, + meta = mempty, + category = Category "other", + description = unsafeRange "test app", + creator = appUser.id + } + collab = + TeamCollaborator + { gUser = appUser.id, + gTeam = teamAId, + gPermissions = mempty + } + localBackend = + def + { users = [appUser, ownerA, ownerB], + apps = [storedApp], + teamCollaborators = Map.fromList [(teamAId, [collab])] + } + teams = + Map.fromList + [ (teamAId, [mkTeamMember ownerA.id fullPermissions Nothing defUserLegalHoldStatus]), + ( teamBId, + [ mkTeamMember ownerB.id fullPermissions Nothing defUserLegalHoldStatus, + mkTeamMember appUser.id fullPermissions Nothing defUserLegalHoldStatus + ] + ) + ] + result :: ([UserId], [UserId]) = + runNoFederationStack localBackend teams config $ do + let f tid caller = + qUnqualified . (.profileQualifiedId) + <$$> getLocalAppProfiles caller tid + in (,) + <$> f teamAId teamAOwnerId + <*> f teamBId teamBOwnerId + in result === ([appUser.id], [appUser.id]) diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 875f1d0bd55..a73319359d5 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -1807,12 +1807,7 @@ getApp lusr tid uid = lift . liftSem $ do getApps :: (_) => Local UserId -> TeamId -> Handler r [UserProfile] getApps lusr tid = lift . liftSem $ do - -- Check if requesting user is a member of the team - requestingUserTeam <- getUserTeam (tUnqualified lusr) - unless (requestingUserTeam == Just tid) $ - throw UserSubsystemProfileNotFound - - getLocalAppProfiles (qualifyAs lusr tid) + getLocalAppProfiles lusr tid putApp :: (_) => Local UserId -> TeamId -> UserId -> Public.PutApp -> Handler r () putApp lusr tid uid put = lift . liftSem $ AppSubsystem.updateApp lusr tid uid put From e50d9445ed9bbd25ae4ecab4be418612a59ea9ff Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Mon, 20 Jul 2026 11:27:43 +0200 Subject: [PATCH 019/113] WPB-26489 backend adminless scheduled jobs model for deletion and reminder (#5289) --- cabal.project | 1 + changelog.d/0-release-notes/WPB-26489 | 5 + changelog.d/2-features/WPB-26489 | 1 + .../background-worker/configmap.yaml | 16 + .../background-worker/deployment.yaml | 1 + charts/wire-server/values.yaml | 28 ++ deploy/dockerephemeral/docker-compose.yaml | 2 +- .../src/developer/reference/config-options.md | 47 +++ flake.lock | 18 + flake.nix | 5 + hack/helm_vars/wire-server/values.yaml.gotmpl | 15 + integration/test/API/Galley.hs | 24 +- integration/test/Notifications.hs | 3 + integration/test/Test/AdminlessGroups.hs | 86 ++++- integration/test/Testlib/Cannon.hs | 25 +- libs/extended/src/Hasql/Pool/Extended.hs | 14 +- libs/types-common/src/Data/Secret.hs | 43 +++ libs/types-common/test/Main.hs | 4 +- libs/types-common/test/Test/Data/Secret.hs | 31 ++ libs/types-common/types-common.cabal | 2 + libs/wire-api/src/Wire/API/BackgroundJobs.hs | 84 ++--- .../src/Wire/API/Event/Conversation.hs | 23 +- libs/wire-api/src/Wire/API/Jobs.hs | 228 +++++++++++++ .../src/Wire/API/Team/FeatureFlags.hs | 7 +- .../golden/Test/Wire/API/Golden/Manual.hs | 20 ++ .../Wire/API/Golden/Manual/AdminlessJobs.hs | 60 ++++ .../testObject_AdminlessDeletionJob_1.json | 5 + .../testObject_AdminlessDeletionJob_2.json | 6 + .../testObject_AdminlessReminderJob_1.json | 6 + .../testObject_AdminlessReminderJob_2.json | 7 + ...sationsJobPayload_AdminlessDeletion_1.json | 8 + ...sationsJobPayload_AdminlessReminder_1.json | 9 + ..._MeetingsJobPayload_MeetingsCleanup_1.json | 4 + .../test/unit/Test/Wire/API/Conversation.hs | 1 + .../unit/Test/Wire/API/Roundtrip/Aeson.hs | 8 +- libs/wire-api/wire-api.cabal | 2 + libs/wire-subsystems/default.nix | 12 + .../src/Wire/BackgroundJobsPublisher.hs | 8 +- .../Wire/BackgroundJobsPublisher/RabbitMQ.hs | 12 +- .../src/Wire/BackgroundJobsRunner.hs | 8 +- .../Wire/BackgroundJobsRunner/Interpreter.hs | 28 +- .../src/Wire/ConversationSubsystem.hs | 10 + .../src/Wire/ConversationSubsystem/Action.hs | 55 +-- .../Wire/ConversationSubsystem/Interpreter.hs | 6 + .../src/Wire/ConversationSubsystem/Update.hs | 188 +++++++++-- libs/wire-subsystems/src/Wire/JobSubsystem.hs | 43 +++ .../src/Wire/JobSubsystem/ArbiterAdapter.hs | 202 +++++++++++ .../src/Wire/JobSubsystem/Interpreter.hs | 111 ++++++ .../src/Wire/JobSubsystem/Migrations.hs | 127 +++++++ .../Wire/UserGroupSubsystem/Interpreter.hs | 24 +- .../ConversationSubsystem/InterpreterSpec.hs | 12 + .../BackgroundJobPublisher.hs | 6 +- .../UserGroupSubsystem/InterpreterSpec.hs | 6 +- libs/wire-subsystems/wire-subsystems.cabal | 8 + nix/haskell-pins.nix | 12 + nix/manual-overrides.nix | 5 + .../background-worker/background-worker.cabal | 4 + .../background-worker.integration.yaml | 15 + services/background-worker/default.nix | 4 + .../src/Wire/AdminlessJobsWorker.hs | 83 +++++ .../src/Wire/BackgroundWorker.hs | 10 +- .../src/Wire/BackgroundWorker/Env.hs | 4 + .../Wire/BackgroundWorker/Jobs/Consumer.hs | 16 +- .../Wire/BackgroundWorker/Jobs/Registry.hs | 12 +- .../src/Wire/BackgroundWorker/Options.hs | 80 ++++- .../src/Wire/BackgroundWorker/Workers.hs | 316 ++++++++++++++++++ .../background-worker/src/Wire/Effects.hs | 32 +- .../src/Wire/MeetingsCleanupWorker.hs | 41 +-- .../Wire/BackendNotificationPusherSpec.hs | 3 + .../background-worker/test/Test/Wire/Util.hs | 2 + services/brig/src/Brig/App.hs | 2 +- .../brig/src/Brig/CanonicalInterpreter.hs | 8 +- services/galley/default.nix | 2 + services/galley/galley.cabal | 1 + services/galley/src/Galley/App.hs | 10 + services/galley/src/Galley/Run.hs | 10 +- 76 files changed, 2167 insertions(+), 220 deletions(-) create mode 100644 changelog.d/0-release-notes/WPB-26489 create mode 100644 changelog.d/2-features/WPB-26489 create mode 100644 libs/types-common/src/Data/Secret.hs create mode 100644 libs/types-common/test/Test/Data/Secret.hs create mode 100644 libs/wire-api/src/Wire/API/Jobs.hs create mode 100644 libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/AdminlessJobs.hs create mode 100644 libs/wire-api/test/golden/testObject_AdminlessDeletionJob_1.json create mode 100644 libs/wire-api/test/golden/testObject_AdminlessDeletionJob_2.json create mode 100644 libs/wire-api/test/golden/testObject_AdminlessReminderJob_1.json create mode 100644 libs/wire-api/test/golden/testObject_AdminlessReminderJob_2.json create mode 100644 libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessDeletion_1.json create mode 100644 libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessReminder_1.json create mode 100644 libs/wire-api/test/golden/testObject_MeetingsJobPayload_MeetingsCleanup_1.json create mode 100644 libs/wire-subsystems/src/Wire/JobSubsystem.hs create mode 100644 libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs create mode 100644 libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs create mode 100644 libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs create mode 100644 services/background-worker/src/Wire/AdminlessJobsWorker.hs create mode 100644 services/background-worker/src/Wire/BackgroundWorker/Workers.hs diff --git a/cabal.project b/cabal.project index 1cf1aa8ecbc..345b1d52076 100644 --- a/cabal.project +++ b/cabal.project @@ -1,6 +1,7 @@ repository hackage.haskell.org url: https://hackage.haskell.org/ index-state: 2023-10-03T15:17:00Z + packages: integration , libs/bilge/ diff --git a/changelog.d/0-release-notes/WPB-26489 b/changelog.d/0-release-notes/WPB-26489 new file mode 100644 index 00000000000..9eb6ee25b5b --- /dev/null +++ b/changelog.d/0-release-notes/WPB-26489 @@ -0,0 +1,5 @@ +Background-worker now runs some new jobs. The background-worker configuration exposes the job dispatcher, worker, retry, shutdown, and reaper settings under `jobs`; all settings default to the current behavior. `jobs.workerThreads` defaults to `1`, so no operator action is required when the default parallelism is sufficient. +Jobs currently use separate `meetings` and `conversations` queues, with one worker pool assigned to each queue. +Each background-worker instance uses one additional PostgreSQL connection for job coordination; increasing `workerThreads` does not increase the connection count. + +The Helm chart sets `background-worker.terminationGracePeriodSeconds` to `40`, providing a margin over the default `jobs.gracefulShutdownTimeout` of `30s`. Adjust both settings together if changing the shutdown timeout. diff --git a/changelog.d/2-features/WPB-26489 b/changelog.d/2-features/WPB-26489 new file mode 100644 index 00000000000..542c6831057 --- /dev/null +++ b/changelog.d/2-features/WPB-26489 @@ -0,0 +1 @@ +Introduce schedulable background jobs, migrate meetings cleanup to the new job runner, and add the initial adminless reminder and deletion jobs. diff --git a/charts/wire-server/templates/background-worker/configmap.yaml b/charts/wire-server/templates/background-worker/configmap.yaml index e7e6f1d2262..d4fe2a63202 100644 --- a/charts/wire-server/templates/background-worker/configmap.yaml +++ b/charts/wire-server/templates/background-worker/configmap.yaml @@ -93,6 +93,22 @@ data: backgroundJobs: {{ toYaml . | indent 6 }} {{- end }} + jobs: + # Arbiter dispatcher poll interval for all jobs. + # Lower values reduce discovery latency, but increase DB polling. + pollInterval: {{ .jobs.pollInterval }} + # Number of worker threads per job queue. + workerThreads: {{ .jobs.workerThreads }} + visibilityTimeout: {{ .jobs.visibilityTimeout }} + jobHeartbeatInterval: {{ .jobs.jobHeartbeatInterval }} + workerHeartbeatInterval: {{ .jobs.workerHeartbeatInterval }} + backoffBase: {{ .jobs.backoffBase }} + backoffCap: {{ .jobs.backoffCap }} + jitter: {{ .jobs.jitter }} + gracefulShutdownTimeout: {{ .jobs.gracefulShutdownTimeout }} + reaperInterval: {{ .jobs.reaperInterval }} + reaperTimeout: {{ .jobs.reaperTimeout }} + workerStaleThreshold: {{ .jobs.workerStaleThreshold }} {{- with .meetingsCleanup }} meetingsCleanup: {{ toYaml . | indent 6 }} diff --git a/charts/wire-server/templates/background-worker/deployment.yaml b/charts/wire-server/templates/background-worker/deployment.yaml index e2f6ef5250e..f91ccb5ee17 100644 --- a/charts/wire-server/templates/background-worker/deployment.yaml +++ b/charts/wire-server/templates/background-worker/deployment.yaml @@ -32,6 +32,7 @@ spec: checksum/galley-secret: {{ include (print .Template.BasePath "/galley/secret.yaml") . | sha256sum }} fluentbit.io/parser: json spec: + terminationGracePeriodSeconds: {{ $backgroundWorker.terminationGracePeriodSeconds }} serviceAccount: null serviceAccountName: null automountServiceAccountToken: false diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml index 59274ebf5fc..34cd2297051 100644 --- a/charts/wire-server/values.yaml +++ b/charts/wire-server/values.yaml @@ -911,6 +911,9 @@ spar: background-worker: replicaCount: 1 + # Must exceed jobs.gracefulShutdownTimeout so Kubernetes does not + # force-kill the worker before it can finish shutting down. + terminationGracePeriodSeconds: 40 image: repository: quay.io/wire/background-worker tag: do-not-use @@ -1002,6 +1005,31 @@ background-worker: # Total attempts, including the first try maxAttempts: 3 + # Job dispatcher configuration. + jobs: + # Arbiter dispatcher poll interval for all jobs. + # Lower values reduce discovery latency, but increase DB polling. + pollInterval: 5s + # Number of worker threads per job queue. + workerThreads: 1 + # How long a claimed job remains invisible while it is processed. + visibilityTimeout: 60s + # How often a running job refreshes its visibility timeout. + jobHeartbeatInterval: 30s + # How often a worker refreshes its own heartbeat. + workerHeartbeatInterval: 10s + # Base and cap for exponential retry backoff. + backoffBase: 2.0 + backoffCap: 86400s + # Retry jitter: none, full, or equal. + jitter: equal + # Maximum time to wait for in-flight jobs during shutdown. + gracefulShutdownTimeout: 30s + # How often the Arbiter reaper runs and how long worker heartbeats remain valid. + reaperInterval: 300s + reaperTimeout: 300s + workerStaleThreshold: 300s + # Meetings cleanup configuration meetingsCleanup: # Delete meetings older than this many hours (48 hours = 2 days) diff --git a/deploy/dockerephemeral/docker-compose.yaml b/deploy/dockerephemeral/docker-compose.yaml index f96f794b019..e88b284487c 100644 --- a/deploy/dockerephemeral/docker-compose.yaml +++ b/deploy/dockerephemeral/docker-compose.yaml @@ -290,7 +290,7 @@ services: POSTGRES_PASSWORD: "posty-the-gres" POSTGRES_USER: "wire-server" POSTGRES_DB: "backendA" - command: postgres -c max_connections=50 + command: postgres -c max_connections=150 cassandra: container_name: demo_wire_cassandra diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index 407e7d9ef4e..61a79ac6298 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -2210,10 +2210,54 @@ backgroundJobs: jobTimeout: 60s # per attempt maxAttempts: 3 # total attempts incl. first run +# Jobs +jobs: + pollInterval: 5s # how often due jobs are discovered + workerThreads: 1 # worker threads for each job queue + visibilityTimeout: 60s # how long a claimed job stays invisible + jobHeartbeatInterval: 30s # refresh interval for running jobs + workerHeartbeatInterval: 10s # refresh interval for worker liveness + backoffBase: 2.0 # exponential retry backoff base + backoffCap: 86400s # maximum exponential retry backoff + jitter: equal # none, full, or equal retry jitter + gracefulShutdownTimeout: 30s # maximum shutdown grace period + reaperInterval: 300s # Arbiter reaper interval + reaperTimeout: 300s # maximum duration of one reaper pass + workerStaleThreshold: 300s # worker heartbeat staleness threshold + +Jobs are currently split into two domain queues: `meetings`, which +contains meeting cleanup jobs, and `conversations`, which contains adminless +reminder and deletion jobs. Each queue has its own Arbiter worker pool. The +`workerThreads` value applies independently to both pools. + +`backgroundJobs` and `jobs` configure different job systems. The +`backgroundJobs` consumer receives immediate user-group synchronization jobs +from RabbitMQ and controls their in-process concurrency, timeout, and retry +behavior. `jobs` runs Arbiter-backed PostgreSQL jobs that may be +scheduled for a future time, including recurring jobs, and controls their +dispatcher, worker-pool, visibility, retry, and reaper behavior. The systems +are separate because they currently use different transports and execution +semantics. They could be merged in the future if the user-group jobs are +migrated to Arbiter. + # Required for addressing local vs remote backends federationDomain: example.org ``` +### Job runner PostgreSQL connections + +Each `background-worker` instance that runs Arbiter jobs uses one additional +PostgreSQL connection for Arbiter scheduler and notification coordination. This +connection is in addition to the connections configured by `postgresqlPool`, +and should be included when sizing PostgreSQL's `max_connections` and the +service's connection budget. + +`jobs.workerThreads` controls how many jobs may be processed +in parallel; it does not allocate one PostgreSQL connection per thread. The +threads share the job worker's database resources, so increasing the +thread count increases possible job and database workload, but not the number +of connections opened by the job worker. + The `migrationOptions.timeout` setting limits how long a single migration attempt may run after it has acquired the migration lock. If the timeout is exceeded, that migration attempt is aborted and treated as failed. @@ -2235,3 +2279,6 @@ Notes - The `migrate...` flags control the corresponding PostgreSQL backfill jobs for the current migration settings; leave them `false` for new installs and after migration. - `concurrency`, `jobTimeout`, and `maxAttempts` control parallelism and retry behavior of the consumer. - `brig` and `gundeck` endpoints default to in-cluster services; override via `background-worker.config.brig` and `.gundeck` if your service DNS/ports differ. +- `jobs` controls the Arbiter dispatcher, worker, retry, shutdown, and reaper settings. All fields default to the values shown above. +- `jobs.pollInterval` controls how often the background worker wakes up to check for due jobs. +- `jobs.workerThreads` controls the number of worker threads in each job queue. The default is `1`; increasing it allows jobs in that queue to run in parallel when their group keys permit it. diff --git a/flake.lock b/flake.lock index 185f73c28e8..ac9b69a8c9a 100644 --- a/flake.lock +++ b/flake.lock @@ -16,6 +16,23 @@ "type": "github" } }, + "arbiter": { + "flake": false, + "locked": { + "lastModified": 1783611005, + "narHash": "sha256-zXAL4NEMhlgSZC2CpmOca67qK6dkUMXVRGTEzf1sMZs=", + "owner": "velveteer", + "repo": "arbiter", + "rev": "296034ea3a15b5c10f42cc0ea46d2dfc48e9493f", + "type": "github" + }, + "original": { + "owner": "velveteer", + "repo": "arbiter", + "rev": "296034ea3a15b5c10f42cc0ea46d2dfc48e9493f", + "type": "github" + } + }, "bloodhound": { "flake": false, "locked": { @@ -367,6 +384,7 @@ "root": { "inputs": { "amazonka": "amazonka", + "arbiter": "arbiter", "bloodhound": "bloodhound", "cql": "cql", "cql-io": "cql-io", diff --git a/flake.nix b/flake.nix index 24c2cf99510..34eab2ffd7c 100644 --- a/flake.nix +++ b/flake.nix @@ -108,6 +108,11 @@ url = "github:wireapp/hasql-resource-pool?rev=5b5d3df0fff81801986a0110acae5420215f01c5"; flake = false; }; + + arbiter = { + url = "github:velveteer/arbiter?rev=296034ea3a15b5c10f42cc0ea46d2dfc48e9493f"; + flake = false; + }; }; outputs = inputs@{ nixpkgs, nixpkgs_24_11, flake-utils, tom-bombadil, sbomnix, ... }: diff --git a/hack/helm_vars/wire-server/values.yaml.gotmpl b/hack/helm_vars/wire-server/values.yaml.gotmpl index 990b49fc07e..16863feea91 100644 --- a/hack/helm_vars/wire-server/values.yaml.gotmpl +++ b/hack/helm_vars/wire-server/values.yaml.gotmpl @@ -628,6 +628,8 @@ federator: background-worker: replicaCount: 1 + # Integration backends do not need graceful job shutdown + terminationGracePeriodSeconds: 0 resources: requests: {} imagePullPolicy: {{ .Values.imagePullPolicy }} @@ -646,6 +648,19 @@ background-worker: concurrency: 8 jobTimeout: 60s maxAttempts: 3 + jobs: + pollInterval: 5s + workerThreads: 1 + visibilityTimeout: 60s + jobHeartbeatInterval: 30s + workerHeartbeatInterval: 10s + backoffBase: 2.0 + backoffCap: 86400s + jitter: equal + gracefulShutdownTimeout: 30s + reaperInterval: 300s + reaperTimeout: 300s + workerStaleThreshold: 300s meetingsCleanup: cleanOlderThanHours: 0.0014 batchSize: 100 diff --git a/integration/test/API/Galley.hs b/integration/test/API/Galley.hs index 480d6c79f6d..a316aa9e309 100644 --- a/integration/test/API/Galley.hs +++ b/integration/test/API/Galley.hs @@ -82,11 +82,18 @@ defConv :: ConversationProtocol -> CreateConv defConv ConversationProtocolProteus = defProteus defConv ConversationProtocolMLS = defMLS +allowAll :: CreateConv -> CreateConv +allowAll cc = + cc + { access = Just ["code", "link", "invite"], + accessRole = Just ["team_member", "guest", "non_team_member", "service"] + } + allowGuests :: CreateConv -> CreateConv allowGuests cc = cc { access = Just ["code"], - accessRole = Just ["team_member", "guest"] + accessRole = Just ["team_member", "non_team_member", "service"] } instance MakesValue CreateConv where @@ -102,7 +109,7 @@ instance MakesValue CreateConv where <> catMaybes [ "name" .=? cc.name, "access" .=? cc.access, - "access_role_v2" .=? cc.access, + "access_role" .=? cc.accessRole, "team" .=? (cc.team <&> \tid -> Aeson.object ["teamid" .= tid, "managed" .= False]), "message_timer" .=? cc.messageTimer, "receipt_mode" .=? cc.receiptMode, @@ -588,6 +595,19 @@ getJoinCodeConv u k v = do req <- baseRequest u Galley Versioned (joinHttpPath ["conversations", "join"]) submit "GET" (req & addQueryParams [("key", k), ("code", v)]) +postJoinCodeConv :: (HasCallStack, MakesValue user) => user -> String -> String -> App Response +postJoinCodeConv u k v = do + req <- baseRequest u Galley Versioned (joinHttpPath ["conversations", "join"]) + submit + "POST" + ( req + & zType "access" + & addJSONObject + [ "key" .= k, + "code" .= v + ] + ) + -- https://staging-nginz-https.zinfra.io/v5/api/swagger-ui/#/default/put_conversations__cnv_domain___cnv__name changeConversationName :: (HasCallStack, MakesValue user, MakesValue conv, MakesValue name) => diff --git a/integration/test/Notifications.hs b/integration/test/Notifications.hs index 9b1aa274a0f..f443cb621bc 100644 --- a/integration/test/Notifications.hs +++ b/integration/test/Notifications.hs @@ -217,6 +217,9 @@ isConvCreateNotifNotSelf n = isConvDeleteNotif :: (HasCallStack, MakesValue a) => a -> App Bool isConvDeleteNotif n = fieldEquals n "payload.0.type" "conversation.delete" +isConvAdminlessReminderNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isConvAdminlessReminderNotif n = fieldEquals n "payload.0.type" "conversation.adminless-reminder" + notifTypeIsEqual :: (HasCallStack, MakesValue a) => String -> a -> App Bool notifTypeIsEqual typ n = nPayload n %. "type" `isEqual` typ diff --git a/integration/test/Test/AdminlessGroups.hs b/integration/test/Test/AdminlessGroups.hs index d2bd97e5e21..f619d27679a 100644 --- a/integration/test/Test/AdminlessGroups.hs +++ b/integration/test/Test/AdminlessGroups.hs @@ -20,7 +20,9 @@ module Test.AdminlessGroups where import API.Brig import API.Galley import API.GalleyInternal hiding (getConversation) +import qualified API.GalleyInternal as GalleyI import MLS.Util +import Notifications import SetupHelpers hiding (deleteUser) import Testlib.Prelude @@ -36,6 +38,9 @@ testOnLastAdminLeaveReturnEligibleMembers = do localUser <- randomUser OwnDomain def connectTwoUsers alice localUser + -- ephemeral user is not eligible + tmpUser <- ephemeralUser OwnDomain + -- a remote user is not eligible remoteUser <- randomUser OtherDomain def connectTwoUsers alice remoteUser @@ -47,14 +52,27 @@ testOnLastAdminLeaveReturnEligibleMembers = do resp.status `shouldMatchInt` 200 resp.json %. "user" - clients@(alice1 : _) <- traverse (createMLSClient def) [alice, bob, localUser, remoteUser, app] + clients@(alice1 : tmpUser1 : _) <- traverse (createMLSClient def) [alice, tmpUser, bob, localUser, remoteUser, app] for_ clients (uploadNewKeyPackage def) - conv <- postConversation alice defMLS {team = Just tid} >>= getJSON 201 + conv <- postConversation alice (allowAll defMLS) {team = Just tid} >>= getJSON 201 convId <- objConvId conv createGroup def alice1 convId void $ createAddCommit alice1 convId [bob, app, localUser, remoteUser] >>= sendAndConsumeCommitBundle + (key, code) <- bindResponse (postConversationCode alice conv Nothing Nothing) $ \resp -> do + res <- getJSON 201 resp + (,) <$> (res %. "data.key" & asString) <*> (res %. "data.code" & asString) + postJoinCodeConv tmpUser key code >>= assertSuccess + void $ createExternalCommit convId tmpUser1 Nothing >>= sendAndConsumeCommitBundle + + GalleyI.getConversation conv `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + members <- resp.json %. "members.others" >>= asList + actual <- traverse (\m -> m %. "qualified_id") members + expected <- traverse (\m -> m %. "qualified_id") [alice, tmpUser, bob, localUser, remoteUser, app] + actual `shouldMatchSet` expected + assertAttemptToLeaveFails conv alice [bob, localUser] -- promote bob to admin @@ -100,19 +118,69 @@ testOnLastAdminLeaveNoEligibleMembersExist = do (alice, tid, _) <- createTeam OwnDomain 1 setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked" - patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "enabled"]) >>= assertSuccess + patchTeamFeature + OwnDomain + tid + "preventAdminlessGroups" + ( object + [ "status" .= "enabled", + "config" + .= object + -- The reminders are due early (+1s and +2s), while deletion is + -- later (+10s). This gives Arbiter's 1s polling and serial + -- grouped-job processing enough room to emit both reminders + -- before the conversation is deleted. + [ "deletionTimeoutDuration" .= "10s", + "reminderTimeoutDurations" .= ["9s", "8s"], + "promotionStrategy" .= "random" + ] + ] + ) + >>= assertSuccess + + let newApp :: NewApp + newApp = def {name = "adminless-reminder-app", description = "not eligible for promotion"} + app <- bindResponse (createApp alice tid newApp) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "user" - alice1 <- createMLSClient def alice - void $ uploadNewKeyPackage def alice1 + tmpUser <- ephemeralUser OwnDomain - conv <- postConversation alice defMLS {team = Just tid} >>= getJSON 201 + clients@(alice1 : tmpUser1 : _) <- traverse (createMLSClient def) [alice, tmpUser, app] + traverse_ (uploadNewKeyPackage def) clients + + conv <- postConversation alice (allowAll defMLS) {team = Just tid} >>= getJSON 201 convId <- objConvId conv createGroup def alice1 convId - void $ createAddCommit alice1 convId [] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [app] >>= sendAndConsumeCommitBundle - -- alice leaves the conversation, no error, group will be marked for deletion - bindResponse (removeMember alice conv alice) $ \resp -> do + (key, code) <- bindResponse (postConversationCode alice conv Nothing Nothing) $ \resp -> do + res <- getJSON 201 resp + (,) <$> (res %. "data.key" & asString) <*> (res %. "data.code" & asString) + postJoinCodeConv tmpUser key code >>= assertSuccess + void $ createExternalCommit convId tmpUser1 Nothing >>= sendAndConsumeCommitBundle + + GalleyI.getConversation conv `bindResponse` \resp -> do resp.status `shouldMatchInt` 200 + members <- resp.json %. "members.others" >>= asList + actual <- traverse (\m -> m %. "qualified_id") members + expected <- traverse (\m -> m %. "qualified_id") [app, alice, tmpUser] + actual `shouldMatchSet` expected + + withWebSockets [app, tmpUser] $ \[wsApp, wsTmpUser] -> do + -- alice leaves the conversation, no error, group will be marked for deletion + bindResponse (removeMember alice conv alice) $ \resp -> do + resp.status `shouldMatchInt` 200 + + void $ awaitNMatches 2 isConvAdminlessReminderNotif wsApp + void $ awaitNMatches 2 isConvAdminlessReminderNotif wsTmpUser + + -- The deletion event is sent after the conversation has been removed. The + -- suite's local timeout is only 2s, but this job is scheduled 10s ahead. + -- Use a longer timeout here and avoid racing the final HTTP assertion. + void $ awaitMatchFor 15 isConvDeleteNotif wsApp + bindResponse (GalleyI.getConversation conv) $ \resp -> do + resp.status `shouldMatchInt` 404 testOnLastAdminLeaveFeatureDisabled :: (HasCallStack) => App () testOnLastAdminLeaveFeatureDisabled = do diff --git a/integration/test/Testlib/Cannon.hs b/integration/test/Testlib/Cannon.hs index 77020825d63..9a8cdb6179f 100644 --- a/integration/test/Testlib/Cannon.hs +++ b/integration/test/Testlib/Cannon.hs @@ -28,6 +28,7 @@ module Testlib.Cannon awaitNMatchesResult, awaitNMatches, awaitMatch, + awaitMatchFor, awaitAnyEvent, awaitAtLeastNMatchesResult, awaitAtLeastNMatches, @@ -320,7 +321,18 @@ awaitNMatchesResult :: (Value -> App Bool) -> WebSocket -> App AwaitResult -awaitNMatchesResult nExpected checkMatch ws = go nExpected [] [] +awaitNMatchesResult nExpected checkMatch ws = do + tSecs <- asks timeOutSeconds + awaitNMatchesResultFor tSecs nExpected checkMatch ws + +awaitNMatchesResultFor :: + (HasCallStack) => + Int -> + Int -> + (Value -> App Bool) -> + WebSocket -> + App AwaitResult +awaitNMatchesResultFor tSecs nExpected checkMatch ws = go nExpected [] [] where go 0 nonMatches matches = do refill nonMatches @@ -332,7 +344,6 @@ awaitNMatchesResult nExpected checkMatch ws = go nExpected [] [] nonMatches = reverse nonMatches } go nLeft nonMatches matches = do - tSecs <- asks timeOutSeconds mEvent <- awaitAnyEvent tSecs ws case mEvent of Just event -> @@ -487,6 +498,16 @@ awaitMatch :: App Value awaitMatch checkMatch ws = head <$> awaitNMatches 1 checkMatch ws +awaitMatchFor :: + (HasCallStack) => + Int -> + (Value -> App Bool) -> + WebSocket -> + App Value +awaitMatchFor tSecs checkMatch ws = do + res <- awaitNMatchesResultFor tSecs 1 checkMatch ws + withWebSocketFailureContext ws $ head <$> assertAwaitResult res + assertNoEvent :: (HasCallStack) => Int -> diff --git a/libs/extended/src/Hasql/Pool/Extended.hs b/libs/extended/src/Hasql/Pool/Extended.hs index 0cd8e4b61c9..31c6bf67cab 100644 --- a/libs/extended/src/Hasql/Pool/Extended.hs +++ b/libs/extended/src/Hasql/Pool/Extended.hs @@ -18,8 +18,9 @@ module Hasql.Pool.Extended where import Data.Aeson -import Data.Map as Map +import Data.Map qualified as Map import Data.Misc +import Data.Secret (SecretText, secretText) import Hasql.Connection qualified import Hasql.Connection.Settings qualified as HasqlConnSettings import Hasql.Pool qualified as HasqlPool @@ -47,6 +48,17 @@ instance FromJSON PoolConfig where <*> o .: "acquisitionTimeout" <*> o .: "idlenessTimeout" +-- | Render a PostgreSQL connection string in libpq key-value format. +-- +-- Passwords from the optional secret file are inserted into the key-value map +-- before rendering. The result is wrapped because it may contain the password. +postgresqlConnectionStringWithPassword :: Map Text Text -> Maybe FilePathSecrets -> IO SecretText +postgresqlConnectionStringWithPassword pgConfig mFpSecrets = do + mPw <- for mFpSecrets initCredentials + let pgConfig' = maybe pgConfig (\pw -> Map.insert "password" pw pgConfig) mPw + pure . secretText . PostgresqlConnectionString.toKeyValueString $ + PostgresqlConnectionString.fromKeyValueParams pgConfig' + data HasqlPoolMetrics = HasqlPoolMetrics { readyForUseGauge :: Gauge, inUseGauge :: Gauge, diff --git a/libs/types-common/src/Data/Secret.hs b/libs/types-common/src/Data/Secret.hs new file mode 100644 index 00000000000..73df5920d22 --- /dev/null +++ b/libs/types-common/src/Data/Secret.hs @@ -0,0 +1,43 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Data.Secret + ( SecretText, + secretText, + revealSecretText, + ) +where + +import Imports + +-- | Text that may contain credentials or other sensitive material. +-- +-- The constructor is intentionally opaque. 'revealSecretText' should only be +-- used at the narrow boundary where an external API requires the plaintext +-- representation. +newtype SecretText = SecretText Text + +instance Show SecretText where + show _ = "" + +-- | Wrap sensitive text without exposing it through the public constructor. +secretText :: Text -> SecretText +secretText = SecretText + +-- | Reveal sensitive text for an API that explicitly requires plaintext. +revealSecretText :: SecretText -> Text +revealSecretText (SecretText value) = value diff --git a/libs/types-common/test/Main.hs b/libs/types-common/test/Main.hs index 4814492dfd0..41bd84e1939 100644 --- a/libs/types-common/test/Main.hs +++ b/libs/types-common/test/Main.hs @@ -23,6 +23,7 @@ where import Imports import Test.Data.Mailbox qualified as Mailbox import Test.Data.PEMKeys qualified as PEMKeys +import Test.Data.Secret qualified as Secret import Test.Domain qualified as Domain import Test.Handle qualified as Handle import Test.Properties qualified as Properties @@ -41,5 +42,6 @@ main = Handle.tests, Qualified.tests, PEMKeys.tests, - Mailbox.tests + Mailbox.tests, + Secret.tests ] diff --git a/libs/types-common/test/Test/Data/Secret.hs b/libs/types-common/test/Test/Data/Secret.hs new file mode 100644 index 00000000000..65ab82c7cdc --- /dev/null +++ b/libs/types-common/test/Test/Data/Secret.hs @@ -0,0 +1,31 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Data.Secret (tests) where + +import Data.Secret +import Imports +import Test.Tasty +import Test.Tasty.HUnit + +tests :: TestTree +tests = + testGroup + "SecretText" + [ testCase "does not expose its value through Show" $ + show (secretText "database-password") @?= "" + ] diff --git a/libs/types-common/types-common.cabal b/libs/types-common/types-common.cabal index e2249067182..7d1590c0571 100644 --- a/libs/types-common/types-common.cabal +++ b/libs/types-common/types-common.cabal @@ -31,6 +31,7 @@ library Data.Qualified Data.Range Data.RetryAfter + Data.Secret Data.SizedHashMap Data.Text.Ascii Data.UUID.Tagged @@ -160,6 +161,7 @@ test-suite types-common-tests Paths_types_common Test.Data.Mailbox Test.Data.PEMKeys + Test.Data.Secret Test.Domain Test.Handle Test.Properties diff --git a/libs/wire-api/src/Wire/API/BackgroundJobs.hs b/libs/wire-api/src/Wire/API/BackgroundJobs.hs index 78f179ed955..b96f2cd7505 100644 --- a/libs/wire-api/src/Wire/API/BackgroundJobs.hs +++ b/libs/wire-api/src/Wire/API/BackgroundJobs.hs @@ -32,39 +32,39 @@ import Network.AMQP qualified as Q import Network.AMQP.Types qualified as QT import Wire.Arbitrary (Arbitrary (..), GenericUniform (..)) -data JobPayload - = JobSyncUserGroupAndChannel SyncUserGroupAndChannel - | JobSyncUserGroup SyncUserGroup +data BackgroundJobPayload + = BackgroundJobSyncUserGroupAndChannel SyncUserGroupAndChannel + | BackgroundJobSyncUserGroup SyncUserGroup deriving stock (Eq, Show, Generic) - deriving (Arbitrary) via GenericUniform JobPayload + deriving (Arbitrary) via GenericUniform BackgroundJobPayload -jobPayloadLabel :: JobPayload -> Text -jobPayloadLabel p = case jobPayloadTag p of - JobSyncUserGroupAndChannelTag -> "sync-user-group-and-channel" - JobSyncUserGroupTag -> "sync-user-group" +backgroundJobPayloadLabel :: BackgroundJobPayload -> Text +backgroundJobPayloadLabel p = case backgroundJobPayloadTag p of + BackgroundJobSyncUserGroupAndChannelTag -> "sync-user-group-and-channel" + BackgroundJobSyncUserGroupTag -> "sync-user-group" -data JobPayloadTag - = JobSyncUserGroupAndChannelTag - | JobSyncUserGroupTag +data BackgroundJobPayloadTag + = BackgroundJobSyncUserGroupAndChannelTag + | BackgroundJobSyncUserGroupTag deriving stock (Eq, Ord, Bounded, Enum, Show, Generic) - deriving (Arbitrary) via GenericUniform JobPayloadTag + deriving (Arbitrary) via GenericUniform BackgroundJobPayloadTag -instance ToSchema JobPayloadTag where +instance ToSchema BackgroundJobPayloadTag where schema = enum @Text $ mconcat - [ element "sync-user-group-and-channel" JobSyncUserGroupAndChannelTag, - element "sync-user-group" JobSyncUserGroupTag + [ element "sync-user-group-and-channel" BackgroundJobSyncUserGroupAndChannelTag, + element "sync-user-group" BackgroundJobSyncUserGroupTag ] -jobPayloadTag :: JobPayload -> JobPayloadTag -jobPayloadTag = +backgroundJobPayloadTag :: BackgroundJobPayload -> BackgroundJobPayloadTag +backgroundJobPayloadTag = \case - JobSyncUserGroupAndChannel {} -> JobSyncUserGroupAndChannelTag - JobSyncUserGroup {} -> JobSyncUserGroupTag + BackgroundJobSyncUserGroupAndChannel {} -> BackgroundJobSyncUserGroupAndChannelTag + BackgroundJobSyncUserGroup {} -> BackgroundJobSyncUserGroupTag -jobPayloadTagSchema :: ObjectSchema SwaggerDoc JobPayloadTag -jobPayloadTagSchema = field "type" schema +backgroundJobPayloadTagSchema :: ObjectSchema SwaggerDoc BackgroundJobPayloadTag +backgroundJobPayloadTagSchema = field "type" schema data SyncUserGroupAndChannel = SyncUserGroupAndChannel { teamId :: TeamId, @@ -102,44 +102,44 @@ instance ToSchema SyncUserGroup where <*> (.userGroupId) .= field "user_group_id" schema <*> (.actor) .= maybe_ (optField "actor" schema) -makePrisms ''JobPayload +makePrisms ''BackgroundJobPayload -jobPayloadObjectSchema :: ObjectSchema SwaggerDoc JobPayload -jobPayloadObjectSchema = +backgroundJobPayloadObjectSchema :: ObjectSchema SwaggerDoc BackgroundJobPayload +backgroundJobPayloadObjectSchema = snd - <$> (jobPayloadTag &&& id) + <$> (backgroundJobPayloadTag &&& id) .= bind - (fst .= jobPayloadTagSchema) - (snd .= dispatch jobPayloadDataSchema) + (fst .= backgroundJobPayloadTagSchema) + (snd .= dispatch backgroundJobPayloadDataSchema) where - jobPayloadDataSchema :: JobPayloadTag -> ObjectSchema SwaggerDoc JobPayload - jobPayloadDataSchema = \case - JobSyncUserGroupAndChannelTag -> tag _JobSyncUserGroupAndChannel (field "payload" schema) - JobSyncUserGroupTag -> tag _JobSyncUserGroup (field "payload" schema) + backgroundJobPayloadDataSchema :: BackgroundJobPayloadTag -> ObjectSchema SwaggerDoc BackgroundJobPayload + backgroundJobPayloadDataSchema = \case + BackgroundJobSyncUserGroupAndChannelTag -> tag _BackgroundJobSyncUserGroupAndChannel (field "payload" schema) + BackgroundJobSyncUserGroupTag -> tag _BackgroundJobSyncUserGroup (field "payload" schema) -instance ToSchema JobPayload where - schema = object jobPayloadObjectSchema +instance ToSchema BackgroundJobPayload where + schema = object backgroundJobPayloadObjectSchema -deriving via (Schema JobPayload) instance Aeson.FromJSON JobPayload +deriving via (Schema BackgroundJobPayload) instance Aeson.FromJSON BackgroundJobPayload -deriving via (Schema JobPayload) instance Aeson.ToJSON JobPayload +deriving via (Schema BackgroundJobPayload) instance Aeson.ToJSON BackgroundJobPayload -deriving via (Schema JobPayload) instance S.ToSchema JobPayload +deriving via (Schema BackgroundJobPayload) instance S.ToSchema BackgroundJobPayload -- | Background job envelope. Payload is a free-form JSON object. -data Job = Job +data BackgroundJob = BackgroundJob { jobId :: JobId, requestId :: RequestId, - payload :: JobPayload + payload :: BackgroundJobPayload } deriving stock (Eq, Show, Generic) - deriving (Arbitrary) via GenericUniform Job - deriving (Aeson.ToJSON, Aeson.FromJSON, S.ToSchema) via Schema Job + deriving (Arbitrary) via GenericUniform BackgroundJob + deriving (Aeson.ToJSON, Aeson.FromJSON, S.ToSchema) via Schema BackgroundJob -instance ToSchema Job where +instance ToSchema BackgroundJob where schema = object $ - Job + BackgroundJob <$> jobId .= field "id" schema <*> requestId .= field "requestId" schema <*> payload .= field "payload" schema diff --git a/libs/wire-api/src/Wire/API/Event/Conversation.hs b/libs/wire-api/src/Wire/API/Event/Conversation.hs index 5ae6a3a5b89..ea2869f273a 100644 --- a/libs/wire-api/src/Wire/API/Event/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Event/Conversation.hs @@ -71,6 +71,7 @@ module Wire.API.Event.Conversation MemberUpdateData (..), OtrMessage (..), ConversationReset (..), + AdminlessReminder (..), -- * re-exports ConversationReceiptModeUpdate (..), @@ -200,6 +201,7 @@ data EventType | MeetingCreate | MeetingUpdate | MeetingDelete + | ConvAdminlessReminder deriving stock (Eq, Show, Generic, Enum, Bounded, Ord) deriving (Arbitrary) via (GenericUniform EventType) deriving (FromJSON, ToJSON, S.ToSchema) via Schema EventType @@ -231,7 +233,8 @@ instance ToSchema EventType where element "conversation.history-update" ConvHistoryUpdate, element "meeting.create" MeetingCreate, element "meeting.update" MeetingUpdate, - element "meeting.delete" MeetingDelete + element "meeting.delete" MeetingDelete, + element "conversation.adminless-reminder" ConvAdminlessReminder ] data EventData @@ -259,6 +262,7 @@ data EventData | EdMeetingCreate (Qualified MeetingId) | EdMeetingUpdate (Qualified MeetingId) | EdMeetingDelete (Qualified MeetingId) + | EdAdminlessReminder AdminlessReminder deriving stock (Eq, Show, Generic) genEventData :: EventType -> QC.Gen EventData @@ -287,6 +291,7 @@ genEventData = \case MeetingCreate -> EdMeetingCreate <$> arbitrary MeetingUpdate -> EdMeetingUpdate <$> arbitrary MeetingDelete -> EdMeetingDelete <$> arbitrary + ConvAdminlessReminder -> EdAdminlessReminder <$> arbitrary eventDataType :: EventData -> EventType eventDataType (EdMembersJoin _) = MemberJoin @@ -313,6 +318,7 @@ eventDataType (EdConvHistoryUpdate _) = ConvHistoryUpdate eventDataType (EdMeetingCreate _) = MeetingCreate eventDataType (EdMeetingUpdate _) = MeetingUpdate eventDataType (EdMeetingDelete _) = MeetingDelete +eventDataType (EdAdminlessReminder _) = ConvAdminlessReminder createConversationEventData :: OwnConversation GroupConvType -> EventData @@ -347,6 +353,7 @@ isCellsConversationEvent eventType = MeetingCreate -> False MeetingUpdate -> False MeetingDelete -> False + ConvAdminlessReminder -> False -------------------------------------------------------------------------------- -- Event data helpers @@ -514,6 +521,19 @@ instance ToSchema ConversationReset where <$> (.groupId) .= field "group_id" schema <*> (.newGroupId) .= maybe_ (optField "new_group_id" schema) +data AdminlessReminder = AdminlessReminder + { deletionScheduledFor :: UTCTimeMillis + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform AdminlessReminder) + deriving (FromJSON, ToJSON, S.ToSchema) via Schema AdminlessReminder + +instance ToSchema AdminlessReminder where + schema = + object $ + AdminlessReminder + <$> (.deletionScheduledFor) .= field "deletion_scheduled_for" schema + makePrisms ''EventData taggedEventDataSchema :: ObjectSchema SwaggerDoc (EventType, EventData) @@ -553,6 +573,7 @@ taggedEventDataSchema = MeetingCreate -> tag _EdMeetingCreate (unnamed schema) MeetingUpdate -> tag _EdMeetingUpdate (unnamed schema) MeetingDelete -> tag _EdMeetingDelete (unnamed schema) + ConvAdminlessReminder -> tag _EdAdminlessReminder (unnamed schema) memberLeaveSchema :: ValueSchema NamedSwaggerDoc (EdMemberLeftReason, QualifiedUserIdList) memberLeaveSchema = diff --git a/libs/wire-api/src/Wire/API/Jobs.hs b/libs/wire-api/src/Wire/API/Jobs.hs new file mode 100644 index 00000000000..58e47c92ff8 --- /dev/null +++ b/libs/wire-api/src/Wire/API/Jobs.hs @@ -0,0 +1,228 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeFamilies #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Jobs where + +import Control.Arrow ((&&&)) +import Control.Lens (makePrisms) +import Data.Aeson (FromJSON, ToJSON) +import Data.Id +import Data.Json.Util +import Data.OpenApi qualified as S +import Data.Proxy +import Data.Schema +import Data.Text as Text +import GHC.TypeLits +import Imports +import Test.QuickCheck (oneof) +import Wire.Arbitrary (Arbitrary (..), GenericUniform (..)) + +-- | The queue/table for jobs that operate on meetings. +type MeetingsQueueName = "meetings" + +meetingsQueueName :: Text +meetingsQueueName = Text.pack $ symbolVal (Proxy @MeetingsQueueName) + +-- | The queue/table for jobs that operate on conversations. +type ConversationsQueueName = "conversations" + +conversationsQueueName :: Text +conversationsQueueName = Text.pack $ symbolVal (Proxy @ConversationsQueueName) + +-- | Empty payload because the schedule itself carries all execution context. +data MeetingsCleanupJob = MeetingsCleanupJob + deriving stock (Eq, Generic, Show) + deriving (ToJSON, FromJSON, S.ToSchema) via (Schema MeetingsCleanupJob) + +instance ToSchema MeetingsCleanupJob where + schema = object $ pure MeetingsCleanupJob + +instance Arbitrary MeetingsCleanupJob where + arbitrary = pure MeetingsCleanupJob + +-- | Payload for adminless deletions. +-- Arbiter persists these payloads and workers decode them later, so changes to +-- field names or shapes require a coordinated rollout. The origin user is +-- optional for jobs created by system reconciliation; the request ID is always +-- captured when a job is scheduled. +data AdminlessDeletionJob = AdminlessDeletionJob + { adminlessDeletionJobTeamId :: TeamId, + adminlessDeletionJobConversationId :: ConvId, + adminlessDeletionJobOrigUserId :: Maybe UserId, + adminlessDeletionJobRequestId :: RequestId + } + deriving stock (Eq, Generic, Show) + deriving (ToJSON, FromJSON, S.ToSchema) via (Schema AdminlessDeletionJob) + +instance Arbitrary AdminlessDeletionJob where + arbitrary = AdminlessDeletionJob <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary + +instance ToSchema AdminlessDeletionJob where + schema = + object $ + AdminlessDeletionJob + <$> (.adminlessDeletionJobTeamId) .= field "team_id" schema + <*> (.adminlessDeletionJobConversationId) .= field "conversation_id" schema + <*> (.adminlessDeletionJobOrigUserId) .= maybe_ (optField "orig_user_id" schema) + <*> (.adminlessDeletionJobRequestId) .= field "request_id" schema + +-- | Payload for adminless reminders. +-- Arbiter persists these payloads and workers decode them later, so changes to +-- field names or shapes require a coordinated rollout. The origin user is +-- optional for jobs created by system reconciliation; the request ID is always +-- captured when a job is scheduled. +data AdminlessReminderJob = AdminlessReminderJob + { adminlessReminderJobTeamId :: TeamId, + adminlessReminderJobConversationId :: ConvId, + adminlessReminderJobOrigUserId :: Maybe UserId, + adminlessReminderJobDeletionScheduledFor :: UTCTimeMillis, + adminlessReminderJobRequestId :: RequestId + } + deriving stock (Eq, Generic, Show) + deriving (ToJSON, FromJSON, S.ToSchema) via (Schema AdminlessReminderJob) + +instance Arbitrary AdminlessReminderJob where + arbitrary = AdminlessReminderJob <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary + +instance ToSchema AdminlessReminderJob where + schema = + object $ + AdminlessReminderJob + <$> (.adminlessReminderJobTeamId) .= field "team_id" schema + <*> (.adminlessReminderJobConversationId) .= field "conversation_id" schema + <*> (.adminlessReminderJobOrigUserId) .= maybe_ (optField "orig_user_id" schema) + <*> (.adminlessReminderJobDeletionScheduledFor) .= field "deletion_scheduled_for" schema + <*> (.adminlessReminderJobRequestId) .= field "request_id" schema + +-- | Common representation for all queue payload envelopes. +-- The queue-specific sum supplies the type tag and its associated data schema, +-- while this helper guarantees the stable {"type": ..., "data": ...} shape. +taggedJobPayloadObjectSchema :: + forall tag payload. + (Bounded tag, Enum tag, ToSchema tag) => + (payload -> tag) -> + (tag -> ObjectSchema SwaggerDoc payload) -> + ObjectSchema SwaggerDoc payload +taggedJobPayloadObjectSchema toTag toSchema = + snd <$> (toTag &&& id) .= bind (fst .= tagObjectSchema) (snd .= dispatch toSchema) + where + tagObjectSchema :: ObjectSchema SwaggerDoc tag + tagObjectSchema = field "type" schema + +-- | Payload persisted in the meetings queue. Keep the type tag and nested data +-- shape stable when changing job payloads. The sum makes the queue +-- extensible without changing its JSON envelope. +data MeetingsJobPayload + = MeetingsCleanup MeetingsCleanupJob + deriving stock (Eq, Generic, Show) + +data MeetingsJobPayloadTag + = MeetingsCleanupTag + deriving stock (Eq, Ord, Bounded, Enum, Show, Generic) + deriving (Arbitrary) via GenericUniform MeetingsJobPayloadTag + +instance ToSchema MeetingsJobPayloadTag where + schema = + enum @Text $ + element "meetings_cleanup" MeetingsCleanupTag + +makePrisms ''MeetingsJobPayload + +meetingsJobPayloadObjectSchema :: ObjectSchema SwaggerDoc MeetingsJobPayload +meetingsJobPayloadObjectSchema = taggedJobPayloadObjectSchema toTag toSchema + where + toTag :: MeetingsJobPayload -> MeetingsJobPayloadTag + toTag = + \case + MeetingsCleanup {} -> MeetingsCleanupTag + + toSchema :: MeetingsJobPayloadTag -> ObjectSchema SwaggerDoc MeetingsJobPayload + toSchema = \case + MeetingsCleanupTag -> tag _MeetingsCleanup (field "data" schema) + +instance ToSchema MeetingsJobPayload where + schema = object meetingsJobPayloadObjectSchema + +deriving via (Schema MeetingsJobPayload) instance FromJSON MeetingsJobPayload + +deriving via (Schema MeetingsJobPayload) instance ToJSON MeetingsJobPayload + +deriving via (Schema MeetingsJobPayload) instance S.ToSchema MeetingsJobPayload + +instance Arbitrary MeetingsJobPayload where + arbitrary = MeetingsCleanup <$> arbitrary + +-- | Payload persisted in the conversations queue. Keep the type tags and +-- nested data shapes stable when changing job payloads. +data ConversationsJobPayload + = AdminlessDeletion AdminlessDeletionJob + | AdminlessReminder AdminlessReminderJob + deriving stock (Eq, Generic, Show) + +data ConversationsJobPayloadTag + = AdminlessReminderTag + | AdminlessDeletionTag + deriving stock (Eq, Ord, Bounded, Enum, Show, Generic) + deriving (Arbitrary) via GenericUniform ConversationsJobPayloadTag + +instance ToSchema ConversationsJobPayloadTag where + schema = + enum @Text $ + mconcat + [ element "adminless_deletion" AdminlessDeletionTag, + element "adminless_reminder" AdminlessReminderTag + ] + +makePrisms ''ConversationsJobPayload + +conversationsJobPayloadObjectSchema :: ObjectSchema SwaggerDoc ConversationsJobPayload +conversationsJobPayloadObjectSchema = taggedJobPayloadObjectSchema toTag toSchema + where + toTag :: ConversationsJobPayload -> ConversationsJobPayloadTag + toTag = + \case + AdminlessDeletion {} -> AdminlessDeletionTag + AdminlessReminder {} -> AdminlessReminderTag + + toSchema :: ConversationsJobPayloadTag -> ObjectSchema SwaggerDoc ConversationsJobPayload + toSchema = \case + AdminlessDeletionTag -> tag _AdminlessDeletion (field "data" schema) + AdminlessReminderTag -> tag _AdminlessReminder (field "data" schema) + +instance ToSchema ConversationsJobPayload where + schema = object conversationsJobPayloadObjectSchema + +deriving via (Schema ConversationsJobPayload) instance FromJSON ConversationsJobPayload + +deriving via (Schema ConversationsJobPayload) instance ToJSON ConversationsJobPayload + +deriving via (Schema ConversationsJobPayload) instance S.ToSchema ConversationsJobPayload + +instance Arbitrary ConversationsJobPayload where + arbitrary = oneof [AdminlessDeletion <$> arbitrary, AdminlessReminder <$> arbitrary] + +-- | Registry for the jobs we expose via Arbiter. +type JobRegistry = + '[ '(MeetingsQueueName, MeetingsJobPayload), + '(ConversationsQueueName, ConversationsJobPayload) + ] diff --git a/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs b/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs index 82d6bea2b08..ed7e457a9a7 100644 --- a/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs +++ b/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs @@ -167,11 +167,10 @@ instance Default (FeatureDefaults SearchVisibilityAvailableConfig) where instance ParseFeatureDefaults (FeatureDefaults SearchVisibilityAvailableConfig) where parseFeatureDefaults obj = do - -- Runtime feature JSON uses the canonical feature key. Keep accepting the - -- legacy configuration key used by existing service YAML files. - mCanonical <- obj .:? "searchVisibility" + -- Accept both the current feature key and the legacy team-scoped key. + mCurrent <- obj .:? "searchVisibility" mLegacy <- obj .:? "teamSearchVisibility" - pure $ fromMaybe def (mCanonical <|> mLegacy) + pure $ fromMaybe def (mCurrent <|> mLegacy) instance FromJSON (FeatureDefaults SearchVisibilityAvailableConfig) where parseJSON (String "enabled-by-default") = pure FeatureTeamSearchVisibilityAvailableByDefault diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs index de8aaafc9fb..385d61ce463 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs @@ -21,6 +21,7 @@ import Imports import Test.Tasty import Test.Tasty.HUnit import Test.Wire.API.Golden.Manual.Activate_user +import Test.Wire.API.Golden.Manual.AdminlessJobs import Test.Wire.API.Golden.Manual.App import Test.Wire.API.Golden.Manual.CannonId import Test.Wire.API.Golden.Manual.ClientCapability @@ -73,6 +74,25 @@ tests = "Manual golden tests" [ testGroup "NewApp" $ testObjects [(testObject_NewApp_1, "testObject_NewApp_1.json")], + testGroup "AdminlessDeletionJob" $ + testObjects + [ (testObject_AdminlessDeletionJob_1, "testObject_AdminlessDeletionJob_1.json"), + (testObject_AdminlessDeletionJob_2, "testObject_AdminlessDeletionJob_2.json") + ], + testGroup "AdminlessReminderJob" $ + testObjects + [ (testObject_AdminlessReminderJob_1, "testObject_AdminlessReminderJob_1.json"), + (testObject_AdminlessReminderJob_2, "testObject_AdminlessReminderJob_2.json") + ], + testGroup "MeetingsJobPayload" $ + testObjects + [ (testObject_MeetingsJobPayload_MeetingsCleanup_1, "testObject_MeetingsJobPayload_MeetingsCleanup_1.json") + ], + testGroup "ConversationsJobPayload" $ + testObjects + [ (testObject_ConversationsJobPayload_AdminlessDeletion_1, "testObject_ConversationsJobPayload_AdminlessDeletion_1.json"), + (testObject_ConversationsJobPayload_AdminlessReminder_1, "testObject_ConversationsJobPayload_AdminlessReminder_1.json") + ], testGroup "CreatedApp" $ testObjects [(testObject_CreatedApp_1, "testObject_CreatedApp_1.json")], testGroup "AppInfo" $ diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/AdminlessJobs.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/AdminlessJobs.hs new file mode 100644 index 00000000000..a85dde3df35 --- /dev/null +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/AdminlessJobs.hs @@ -0,0 +1,60 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Wire.API.Golden.Manual.AdminlessJobs where + +import Data.Id +import Data.Json.Util (UTCTimeMillis, readUTCTimeMillis) +import Data.UUID qualified as UUID +import Imports +import Wire.API.Jobs + +teamId :: TeamId +teamId = Id . fromJust $ UUID.fromString "00000000-0000-0000-0000-000000000001" + +conversationId :: ConvId +conversationId = Id . fromJust $ UUID.fromString "00000000-0000-0000-0000-000000000002" + +originUserId :: UserId +originUserId = Id . fromJust $ UUID.fromString "00000000-0000-0000-0000-000000000003" + +requestId :: RequestId +requestId = RequestId "golden-adminless-job" + +deletionScheduledFor :: UTCTimeMillis +deletionScheduledFor = fromJust $ readUTCTimeMillis "2026-07-14T12:00:00.000Z" + +testObject_AdminlessDeletionJob_1 :: AdminlessDeletionJob +testObject_AdminlessDeletionJob_1 = AdminlessDeletionJob teamId conversationId Nothing requestId + +testObject_AdminlessDeletionJob_2 :: AdminlessDeletionJob +testObject_AdminlessDeletionJob_2 = AdminlessDeletionJob teamId conversationId (Just originUserId) requestId + +testObject_AdminlessReminderJob_1 :: AdminlessReminderJob +testObject_AdminlessReminderJob_1 = AdminlessReminderJob teamId conversationId Nothing deletionScheduledFor requestId + +testObject_AdminlessReminderJob_2 :: AdminlessReminderJob +testObject_AdminlessReminderJob_2 = AdminlessReminderJob teamId conversationId (Just originUserId) deletionScheduledFor requestId + +testObject_MeetingsJobPayload_MeetingsCleanup_1 :: MeetingsJobPayload +testObject_MeetingsJobPayload_MeetingsCleanup_1 = MeetingsCleanup MeetingsCleanupJob + +testObject_ConversationsJobPayload_AdminlessDeletion_1 :: ConversationsJobPayload +testObject_ConversationsJobPayload_AdminlessDeletion_1 = AdminlessDeletion testObject_AdminlessDeletionJob_1 + +testObject_ConversationsJobPayload_AdminlessReminder_1 :: ConversationsJobPayload +testObject_ConversationsJobPayload_AdminlessReminder_1 = AdminlessReminder testObject_AdminlessReminderJob_1 diff --git a/libs/wire-api/test/golden/testObject_AdminlessDeletionJob_1.json b/libs/wire-api/test/golden/testObject_AdminlessDeletionJob_1.json new file mode 100644 index 00000000000..1fc08afe734 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_AdminlessDeletionJob_1.json @@ -0,0 +1,5 @@ +{ + "conversation_id": "00000000-0000-0000-0000-000000000002", + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" +} diff --git a/libs/wire-api/test/golden/testObject_AdminlessDeletionJob_2.json b/libs/wire-api/test/golden/testObject_AdminlessDeletionJob_2.json new file mode 100644 index 00000000000..1c007054945 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_AdminlessDeletionJob_2.json @@ -0,0 +1,6 @@ +{ + "conversation_id": "00000000-0000-0000-0000-000000000002", + "orig_user_id": "00000000-0000-0000-0000-000000000003", + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" +} diff --git a/libs/wire-api/test/golden/testObject_AdminlessReminderJob_1.json b/libs/wire-api/test/golden/testObject_AdminlessReminderJob_1.json new file mode 100644 index 00000000000..786f5d358f0 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_AdminlessReminderJob_1.json @@ -0,0 +1,6 @@ +{ + "conversation_id": "00000000-0000-0000-0000-000000000002", + "deletion_scheduled_for": "2026-07-14T12:00:00.000Z", + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" +} diff --git a/libs/wire-api/test/golden/testObject_AdminlessReminderJob_2.json b/libs/wire-api/test/golden/testObject_AdminlessReminderJob_2.json new file mode 100644 index 00000000000..fb6056b3fcf --- /dev/null +++ b/libs/wire-api/test/golden/testObject_AdminlessReminderJob_2.json @@ -0,0 +1,7 @@ +{ + "conversation_id": "00000000-0000-0000-0000-000000000002", + "deletion_scheduled_for": "2026-07-14T12:00:00.000Z", + "orig_user_id": "00000000-0000-0000-0000-000000000003", + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" +} diff --git a/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessDeletion_1.json b/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessDeletion_1.json new file mode 100644 index 00000000000..1a66a9225db --- /dev/null +++ b/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessDeletion_1.json @@ -0,0 +1,8 @@ +{ + "data": { + "conversation_id": "00000000-0000-0000-0000-000000000002", + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" + }, + "type": "adminless_deletion" +} diff --git a/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessReminder_1.json b/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessReminder_1.json new file mode 100644 index 00000000000..9d6b2b9eea7 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessReminder_1.json @@ -0,0 +1,9 @@ +{ + "data": { + "conversation_id": "00000000-0000-0000-0000-000000000002", + "deletion_scheduled_for": "2026-07-14T12:00:00.000Z", + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" + }, + "type": "adminless_reminder" +} diff --git a/libs/wire-api/test/golden/testObject_MeetingsJobPayload_MeetingsCleanup_1.json b/libs/wire-api/test/golden/testObject_MeetingsJobPayload_MeetingsCleanup_1.json new file mode 100644 index 00000000000..550e044ca10 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_MeetingsJobPayload_MeetingsCleanup_1.json @@ -0,0 +1,4 @@ +{ + "data": {}, + "type": "meetings_cleanup" +} diff --git a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs index 4c9f6fbe679..f3651238321 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs @@ -75,6 +75,7 @@ testIsCellsConversationEvent = ConvReset -> isCellsConversationEvent e === False ConvMessageTimerUpdate -> isCellsConversationEvent e === False ConvHistoryUpdate -> isCellsConversationEvent e === False + ConvAdminlessReminder -> isCellsConversationEvent e === False ConvReceiptModeUpdate -> isCellsConversationEvent e === False ConvRename -> isCellsConversationEvent e === True MemberJoin -> isCellsConversationEvent e === True diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs index d7a7a647eb5..9f3b207bbf0 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs @@ -44,6 +44,7 @@ import Wire.API.Event.Conversation qualified as Event.Conversation import Wire.API.Event.Team qualified as Event.Team import Wire.API.Event.WebSocketProtocol qualified as EventWebSocketProtocol import Wire.API.FederationStatus qualified as FederationStatus +import Wire.API.Jobs qualified as Jobs import Wire.API.Locale qualified as Locale import Wire.API.Message qualified as Message import Wire.API.OAuth qualified as OAuth @@ -156,6 +157,11 @@ tests = testRoundTrip @Conversation.Role.ConversationRolesList, testRoundTrip @Conversation.Typing.TypingStatus, testRoundTrip @CustomBackend.CustomBackend, + testRoundTrip @Jobs.MeetingsCleanupJob, + testRoundTripWithSwagger @Jobs.AdminlessDeletionJob, + testRoundTripWithSwagger @Jobs.AdminlessReminderJob, + testRoundTrip @Jobs.MeetingsJobPayload, + testRoundTrip @Jobs.ConversationsJobPayload, testRoundTrip @EJPD.EJPDContact, testRoundTrip @Event.Conversation.Event, testRoundTrip @Event.Conversation.EventType, @@ -372,7 +378,7 @@ tests = testRoundTrip @TeamsIntra.TeamStatusUpdate, testRoundTrip @TeamsIntra.TeamData, testRoundTrip @TeamsIntra.TeamName, - testRoundTrip @BackgroundJobs.Job, + testRoundTrip @BackgroundJobs.BackgroundJob, testRoundTrip @User.ManagedByUpdate, testRoundTrip @User.Auth.ReAuthUser, testRoundTrip @User.RichInfoUpdate, diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index 835617908cb..77cd01c4e9f 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -112,6 +112,7 @@ library Wire.API.History Wire.API.Internal.BulkPush Wire.API.Internal.Notification + Wire.API.Jobs Wire.API.Locale Wire.API.Meeting Wire.API.Message @@ -615,6 +616,7 @@ test-suite wire-api-golden-tests Test.Wire.API.Golden.Generated.Wrapped_20_22some_5fint_22_20Int_user Test.Wire.API.Golden.Manual Test.Wire.API.Golden.Manual.Activate_user + Test.Wire.API.Golden.Manual.AdminlessJobs Test.Wire.API.Golden.Manual.App Test.Wire.API.Golden.Manual.CannonId Test.Wire.API.Golden.Manual.ClientCapability diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix index cbda44f339a..c522d4489a2 100644 --- a/libs/wire-subsystems/default.nix +++ b/libs/wire-subsystems/default.nix @@ -11,6 +11,9 @@ , amazonka-ses , amazonka-sqs , amqp +, arbiter-core +, arbiter-hasql +, arbiter-migrations , async , attoparsec , base @@ -30,6 +33,7 @@ , contravariant , cookie , cql +, cron , crypton , crypton-asn1-encoding , crypton-asn1-types @@ -152,6 +156,9 @@ mkDerivation { amazonka-ses amazonka-sqs amqp + arbiter-core + arbiter-hasql + arbiter-migrations async attoparsec base @@ -171,6 +178,7 @@ mkDerivation { contravariant cookie cql + cron crypton crypton-asn1-encoding crypton-asn1-types @@ -281,6 +289,9 @@ mkDerivation { amazonka-ses amazonka-sqs amqp + arbiter-core + arbiter-hasql + arbiter-migrations async attoparsec base @@ -299,6 +310,7 @@ mkDerivation { contravariant cookie cql + cron crypton crypton-asn1-encoding crypton-asn1-types diff --git a/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher.hs b/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher.hs index 0cca777728b..3c3012e876c 100644 --- a/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher.hs +++ b/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher.hs @@ -21,9 +21,9 @@ module Wire.BackgroundJobsPublisher where import Data.Id import Polysemy -import Wire.API.BackgroundJobs (JobPayload) +import Wire.API.BackgroundJobs (BackgroundJobPayload) -data BackgroundJobsPublisher m a where - PublishJob :: JobId -> JobPayload -> BackgroundJobsPublisher m () +data BackgroundJobPublisher m a where + PublishJob :: JobId -> BackgroundJobPayload -> BackgroundJobPublisher m () -makeSem ''BackgroundJobsPublisher +makeSem ''BackgroundJobPublisher diff --git a/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher/RabbitMQ.hs b/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher/RabbitMQ.hs index fecd5ca6bd2..1b3e9cb273b 100644 --- a/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher/RabbitMQ.hs +++ b/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher/RabbitMQ.hs @@ -24,14 +24,14 @@ import Imports import Network.AMQP qualified as Q import Polysemy import Wire.API.BackgroundJobs -import Wire.BackgroundJobsPublisher (BackgroundJobsPublisher (..)) +import Wire.BackgroundJobsPublisher (BackgroundJobPublisher (..)) -interpretBackgroundJobsPublisherRabbitMQ :: +interpretBackgroundJobPublisherRabbitMQ :: (Member (Embed IO) r) => RequestId -> MVar Q.Channel -> - InterpreterFor BackgroundJobsPublisher r -interpretBackgroundJobsPublisherRabbitMQ requestId channelMVar = + InterpreterFor BackgroundJobPublisher r +interpretBackgroundJobPublisherRabbitMQ requestId channelMVar = interpret $ \case PublishJob jobId jobPayload -> do channel <- readMVar channelMVar @@ -42,11 +42,11 @@ publishJob :: RequestId -> Q.Channel -> JobId -> - JobPayload -> + BackgroundJobPayload -> Sem r () publishJob requestId channel jobId jobPayload = do let job = - Job + BackgroundJob { payload = jobPayload, jobId = jobId, requestId = requestId diff --git a/libs/wire-subsystems/src/Wire/BackgroundJobsRunner.hs b/libs/wire-subsystems/src/Wire/BackgroundJobsRunner.hs index a3247884945..d54e2d64a07 100644 --- a/libs/wire-subsystems/src/Wire/BackgroundJobsRunner.hs +++ b/libs/wire-subsystems/src/Wire/BackgroundJobsRunner.hs @@ -20,9 +20,9 @@ module Wire.BackgroundJobsRunner where import Polysemy -import Wire.API.BackgroundJobs (Job) +import Wire.API.BackgroundJobs (BackgroundJob) -data BackgroundJobsRunner m a where - RunJob :: Job -> BackgroundJobsRunner m () +data BackgroundJobRunner m a where + RunJob :: BackgroundJob -> BackgroundJobRunner m () -makeSem ''BackgroundJobsRunner +makeSem ''BackgroundJobRunner diff --git a/libs/wire-subsystems/src/Wire/BackgroundJobsRunner/Interpreter.hs b/libs/wire-subsystems/src/Wire/BackgroundJobsRunner/Interpreter.hs index a319f729a8c..8fc5abdc680 100644 --- a/libs/wire-subsystems/src/Wire/BackgroundJobsRunner/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/BackgroundJobsRunner/Interpreter.hs @@ -42,7 +42,7 @@ import Wire.API.Event.Conversation import Wire.API.Team.HardTruncationLimit (hardTruncationLimit) import Wire.API.UserGroup import Wire.BackgroundJobsPublisher -import Wire.BackgroundJobsRunner (BackgroundJobsRunner (..)) +import Wire.BackgroundJobsRunner (BackgroundJobRunner (..)) import Wire.ConversationStore (ConversationStore, upsertMembers) import Wire.ConversationSubsystem import Wire.Sem.Random @@ -50,33 +50,33 @@ import Wire.StoredConversation import Wire.UserGroupStore (UserGroupStore, getUserGroup, getUserGroupChannels) import Wire.UserList (toUserList) -interpretBackgroundJobsRunner :: +interpretBackgroundJobRunner :: ( Member UserGroupStore r, - Member BackgroundJobsPublisher r, + Member BackgroundJobPublisher r, Member (Input (Local ())) r, Member ConversationStore r, Member ConversationSubsystem r, Member Random r, Member TinyLog r ) => - InterpreterFor BackgroundJobsRunner r -interpretBackgroundJobsRunner = interpret $ \case - RunJob job -> runJob job + InterpreterFor BackgroundJobRunner r +interpretBackgroundJobRunner = interpret $ \case + RunJob job -> runBackgroundJob job -runJob :: +runBackgroundJob :: ( Member UserGroupStore r, - Member BackgroundJobsPublisher r, + Member BackgroundJobPublisher r, Member (Input (Local ())) r, Member ConversationStore r, Member ConversationSubsystem r, Member Random r, Member TinyLog r ) => - Job -> + BackgroundJob -> Sem r () -runJob job = case job.payload of - JobSyncUserGroupAndChannel payload -> runSyncUserGroupAndChannel payload - JobSyncUserGroup payload -> runSyncUserGroup payload +runBackgroundJob job = case job.payload of + BackgroundJobSyncUserGroupAndChannel payload -> runSyncUserGroupAndChannel payload + BackgroundJobSyncUserGroup payload -> runSyncUserGroup payload runSyncUserGroupAndChannel :: ( Member UserGroupStore r, @@ -145,7 +145,7 @@ runSyncUserGroupAndChannel (SyncUserGroupAndChannel {..}) = do runSyncUserGroup :: ( Member UserGroupStore r, - Member BackgroundJobsPublisher r, + Member BackgroundJobPublisher r, Member Random r, Member TinyLog r ) => @@ -162,4 +162,4 @@ runSyncUserGroup SyncUserGroup {..} = do for_ channels $ \convId -> do let syncUserGroupAndChannel = SyncUserGroupAndChannel {..} jobId <- newId - publishJob jobId (JobSyncUserGroupAndChannel syncUserGroupAndChannel) + publishJob jobId (BackgroundJobSyncUserGroupAndChannel syncUserGroupAndChannel) diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs index d1991691258..d6beb8c33f3 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs @@ -32,6 +32,7 @@ import Data.Code qualified as Code import Data.CommaSeparatedList (CommaSeparatedList) import Data.Domain import Data.Id +import Data.Json.Util (UTCTimeMillis) import Data.Misc (IpAddr) import Data.Qualified import Data.Range @@ -341,6 +342,15 @@ data ConversationSubsystem m a where InternalDeleteLocalConversation :: Local ConvId -> ConversationSubsystem m () + InternalDeleteLocalAdminlessGroup :: + Maybe (Local UserId) -> + Local ConvId -> + ConversationSubsystem m () + InternalNotifyAdminlessReminder :: + Maybe (Local UserId) -> + Local ConvId -> + UTCTimeMillis -> + ConversationSubsystem m () GetMLSPublicKeys :: Maybe MLSPublicKeyFormat -> ConversationSubsystem m (MLSKeysByPurpose (MLSKeys SomeKey)) diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs index a6e6921db12..f7c128e29d8 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs @@ -51,6 +51,7 @@ module Wire.ConversationSubsystem.Action addLocalUsersToRemoteConv, ConversationUpdate, ensureAllowed, + removeConversation, ) where @@ -115,6 +116,7 @@ import Wire.BrigAPIAccess qualified as E import Wire.CodeStore import Wire.CodeStore qualified as E import Wire.CodeStore.Code (CodeReferent (..)) +import Wire.ConversationStore (ConversationStore) import Wire.ConversationStore qualified as E import Wire.ConversationSubsystem.Action.Kick import Wire.ConversationSubsystem.Action.Leave @@ -375,28 +377,7 @@ instance IsConversationAction 'ConversationDeleteTag where ] performAction lconv _qusr _conId () = do - let lcnv = fmap (.id_) lconv - storedConv = tUnqualified lconv - let deleteGroup groupId = do - E.removeAllMLSClients groupId - E.removeAllHistoryClients groupId - E.deleteAllProposals groupId - - let cid = storedConv.id_ - for_ (storedConv & mlsMetadata <&> cnvmlsGroupId . fst) $ \gidParent -> do - sconvs <- E.listSubConversations cid - for_ (Map.assocs sconvs) $ \(subid, mlsData) -> do - let gidSub = cnvmlsGroupId mlsData - E.deleteSubConversation cid subid - deleteGroup gidSub - deleteGroup gidParent - - key <- E.makeKey (CodeReferentConv (tUnqualified lcnv)) - E.deleteCode key - case convTeam storedConv of - Nothing -> E.deleteConversation (tUnqualified lcnv) - Just tid -> E.deleteTeamConversation tid (tUnqualified lcnv) - + removeConversation lconv pure $ mkPerformActionResult () ensureAllowed _ _action _conv (ActorContext Nothing (Just _tm)) = @@ -409,6 +390,36 @@ instance IsConversationAction 'ConversationDeleteTag where allowChannelManagePermission = True +removeConversation :: + ( Member ConversationStore r, + Member ProposalStore r, + Member CodeStore r + ) => + Local StoredConversation -> + Sem r () +removeConversation lconv = do + let lcnv = fmap (.id_) lconv + storedConv = tUnqualified lconv + let deleteGroup groupId = do + E.removeAllMLSClients groupId + E.removeAllHistoryClients groupId + E.deleteAllProposals groupId + + let cid = storedConv.id_ + for_ (storedConv & mlsMetadata <&> cnvmlsGroupId . fst) $ \gidMainConv -> do + sconvs <- E.listSubConversations cid + for_ (Map.assocs sconvs) $ \(subid, mlsData) -> do + let gidSub = cnvmlsGroupId mlsData + E.deleteSubConversation cid subid + deleteGroup gidSub + deleteGroup gidMainConv + + key <- E.makeKey (CodeReferentConv (tUnqualified lcnv)) + E.deleteCode key + case convTeam storedConv of + Nothing -> E.deleteConversation (tUnqualified lcnv) + Just tid -> E.deleteTeamConversation tid (tUnqualified lcnv) + instance IsConversationAction 'ConversationRenameTag where type HasConversationActionEffects 'ConversationRenameTag r = diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs index 972ec7573b8..884e707d313 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs @@ -71,6 +71,7 @@ import Wire.FederationAPIAccess (FederationAPIAccess) import Wire.FederationSubsystem (FederationSubsystem) import Wire.FireAndForget (FireAndForget) import Wire.HashPassword (HashPassword) +import Wire.JobSubsystem (JobSubsystem) import Wire.LegalHoldStore (LegalHoldStore) import Wire.NotificationSubsystem as NS import Wire.Options.Galley (GuestLinkTTLSeconds) @@ -115,6 +116,7 @@ interpretConversationSubsystem :: Member TeamStore r, Member ConvStore.MLSCommitLockStore r, Member FederationSubsystem r, + Member JobSubsystem r, Member Resource r, Member (Input (Maybe (MLSKeysByPurpose MLSPrivateKeys))) r, Member UserClientIndexStore r, @@ -211,6 +213,10 @@ interpretConversationSubsystem = interpret $ \case mapErrors $ Update.deleteLocalConversation lusr con lcnv InternalDeleteLocalConversation lcnv -> mapErrors $ Action.updateLocalConversationDeleteUnchecked lcnv + InternalDeleteLocalAdminlessGroup lusr lcnv -> + mapErrors $ Update.adminlessAutopromoteOrDelete lusr lcnv + InternalNotifyAdminlessReminder lusr lcnv deletionScheduledFor -> + mapErrors $ Update.adminlessAutopromoteOrSendReminder lusr lcnv deletionScheduledFor GetMLSPublicKeys fmt -> mapErrors $ MLS.getMLSPublicKeys fmt ResetMLSConversation lusr reset -> diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs index a96558746bd..599892d5bad 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs @@ -42,6 +42,8 @@ module Wire.ConversationSubsystem.Update updateConversationProtocolWithLocalUser, updateLocalStateOfRemoteConv, updateCellsState, + adminlessAutopromoteOrDelete, + adminlessAutopromoteOrSendReminder, -- * Managing Members addQualifiedMembersUnqualified, @@ -84,6 +86,7 @@ import Data.Misc import Data.Qualified import Data.Set qualified as Set import Data.Singletons +import Data.Time.Clock (NominalDiffTime, addUTCTime) import Data.Vector qualified as V import Galley.Types.Error import Imports hiding (forkIO) @@ -140,6 +143,7 @@ import Wire.FederationAPIAccess qualified as E import Wire.FederationSubsystem import Wire.FireAndForget import Wire.HashPassword as HashPassword +import Wire.JobSubsystem (JobSubsystem, scheduleAdminlessDeletionJob, scheduleAdminlessReminderJob) import Wire.LegalHoldStore (LegalHoldStore) import Wire.NotificationSubsystem import Wire.Options.Galley @@ -972,6 +976,7 @@ replaceMembers :: Member FederationSubsystem r, Member FeaturesConfigSubsystem r, Member TeamSubsystem r, + Member JobSubsystem r, Member (Input ConversationSubsystemConfig) r ) => Local UserId -> @@ -1153,6 +1158,7 @@ removeMemberQualified :: Member TinyLog r, Member FeaturesConfigSubsystem r, Member TeamSubsystem r, + Member JobSubsystem r, Member (Input ConversationSubsystemConfig) r ) => RemoveMemberResponseMode -> @@ -1185,7 +1191,8 @@ guardPreventAdminlessGroups :: Member E.ExternalAccess r, Member BackendNotificationQueueAccess r, Member TeamSubsystem r, - Member FeaturesConfigSubsystem r + Member FeaturesConfigSubsystem r, + Member JobSubsystem r ) => RemoveMemberResponseMode -> Local ConvId -> @@ -1197,7 +1204,7 @@ guardPreventAdminlessGroups responseMode lcnv lusr victim = do for_ conv.metadata.cnvmTeam $ \tid -> do (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid when (feature.status == FeatureStatusEnabled && isLeavingLastConversationAdmin (qUnqualified victim) conv) $ do - eligibleMembers <- eligibleAdminFallbackMembers lcnv (qUnqualified victim) conv + eligibleMembers <- eligibleAdminFallbackMembers lcnv (Just (qUnqualified victim)) conv case (responseMode, eligibleMembers) of (RemoveMemberLegacyResponse, x : xs) -> do seed <- randomWord64 @@ -1208,18 +1215,159 @@ guardPreventAdminlessGroups responseMode lcnv lusr victim = do (RemoveMemberEligibleMembersResponse, _ : _) -> throw $ AdminlessConversation (fmap fst eligibleMembers) (RemoveMemberLegacyResponse, []) -> - -- FUTUREWORK: mark for deletion - pure () - (RemoveMemberEligibleMembersResponse, []) -> do - -- FUTUREWORK: mark for deletion - pure () + scheduleDeletion tid feature + (RemoveMemberEligibleMembersResponse, []) -> + scheduleDeletion tid feature where - -- Use eight random bytes and fold them into a big-endian Word64. This keeps - -- the helper small, deterministic under tests, and free of extra Random API. - randomWord64 :: (Member Random r) => Sem r Word64 - randomWord64 = BS.foldl' step 0 <$> Random.bytes 8 - where - step acc byte = shiftL acc 8 .|. fromIntegral byte + scheduleDeletion tid feature = do + now <- Now.get + let deletionTimeout = timeoutToNominalDiffTime feature.config.deletionTimeout + scheduledFor = addUTCTime deletionTimeout now + deletionScheduledFor = toUTCTimeMillis scheduledFor + void $ scheduleAdminlessDeletionJob (Just lusr) tid (qUnqualified (tUntagged lcnv)) scheduledFor + for_ feature.config.reminderTimeouts $ + scheduleReminder now tid deletionScheduledFor deletionTimeout + + scheduleReminder now tid deletionScheduledFor deletionTimeout reminderTimeoutCfg = do + let reminderTimeout = timeoutToNominalDiffTime reminderTimeoutCfg + when (reminderTimeout < deletionTimeout) $ do + let reminderAt = addUTCTime (deletionTimeout - reminderTimeout) now + void $ + scheduleAdminlessReminderJob + (Just lusr) + tid + (qUnqualified (tUntagged lcnv)) + deletionScheduledFor + reminderTimeout + reminderAt + + timeoutToNominalDiffTime :: PreventAdminlessTimeout -> NominalDiffTime + timeoutToNominalDiffTime = + realToFrac . duration . durationLiteralValue . preventAdminlessTimeoutLiteral + +onAdminless :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member BrigAPIAccess r, + Member FeaturesConfigSubsystem r + ) => + Local ConvId -> + (StoredConversation -> LockableFeature PreventAdminlessGroupsConfig -> [(Qualified UserId, User.Name)] -> Sem r ()) -> + Sem r () +onAdminless lcnv action = do + conv <- getConversationWithError lcnv + for_ conv.metadata.cnvmTeam $ \tid -> do + (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid + let adminExists = any (\member -> member.convRoleName == roleNameWireAdmin) conv.localMembers || any (\member -> member.convRoleName == roleNameWireAdmin) conv.remoteMembers + when (feature.status == FeatureStatusEnabled && not adminExists) $ do + eligibleMembers <- eligibleAdminFallbackMembers lcnv Nothing conv + action conv feature eligibleMembers + +adminlessTryAutopromote :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member (Error FederationError) r, + Member BrigAPIAccess r, + Member Random r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member BackendNotificationQueueAccess r, + Member FeaturesConfigSubsystem r + ) => + Maybe (Local UserId) -> + Local ConvId -> + (StoredConversation -> Sem r ()) -> + Sem r () +adminlessTryAutopromote mlusr lcnv altAction = do + onAdminless lcnv $ \conv feature eligibleMembers -> do + case eligibleMembers of + x : xs -> do + seed <- randomWord64 + let autopromotionCandidates = selectAutopromotionCandidate seed feature.config.promotionStrategy (x :| xs) + update = (OtherMemberUpdate (Just roleNameWireAdmin)) + for_ autopromotionCandidates $ \candidate -> do + E.setOtherMember lcnv candidate update + for_ mlusr \lusr -> + sendConversationActionNotifications + (sing @'ConversationMemberUpdateTag) + (tUntagged lusr) + False + Nothing + (qualifyAs lcnv conv) + (convBotsAndMembers conv) + (ConversationMemberUpdate (tUntagged lusr) update) + def + [] -> altAction conv + +adminlessAutopromoteOrDelete :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member (Error FederationError) r, + Member BrigAPIAccess r, + Member Random r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member BackendNotificationQueueAccess r, + Member FeaturesConfigSubsystem r, + Member ProposalStore r, + Member CodeStore r + ) => + Maybe (Local UserId) -> + Local ConvId -> + Sem r () +adminlessAutopromoteOrDelete mlusr lcnv = adminlessTryAutopromote mlusr lcnv orAlternativelyDeleteConv + where + orAlternativelyDeleteConv conv = do + removeConversation (qualifyAs lcnv conv) + for_ mlusr $ \lusr -> + sendConversationActionNotifications + (sing @'ConversationDeleteTag) + (tUntagged lusr) + False + Nothing + (qualifyAs lcnv conv) + (convBotsAndMembers conv) + () + def + +adminlessAutopromoteOrSendReminder :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member (Error FederationError) r, + Member BrigAPIAccess r, + Member Random r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member BackendNotificationQueueAccess r, + Member FeaturesConfigSubsystem r + ) => + Maybe (Local UserId) -> + Local ConvId -> + UTCTimeMillis -> + Sem r () +adminlessAutopromoteOrSendReminder mlusr lcnv deletionScheduledFor = adminlessTryAutopromote mlusr lcnv orAlternativelySendReminder + where + orAlternativelySendReminder conv = for_ mlusr \lusr -> do + now <- Now.get + let event = + Event + (tUntagged lcnv) + Nothing + (EventFromUser (tUntagged lusr)) + now + (conv.metadata.cnvmTeam) + (EdAdminlessReminder (AdminlessReminder deletionScheduledFor)) + pushConversationEvent Nothing conv event (qualifyAs lcnv (map (.id_) conv.localMembers)) [] + +-- Use eight random bytes and fold them into a big-endian Word64. This keeps +-- the helper small, deterministic under tests, and free of extra Random API. +randomWord64 :: (Member Random r) => Sem r Word64 +randomWord64 = BS.foldl' step 0 <$> Random.bytes 8 + where + step acc byte = shiftL acc 8 .|. fromIntegral byte isLeavingLastConversationAdmin :: UserId -> StoredConversation -> Bool isLeavingLastConversationAdmin leavingUser conv = @@ -1234,16 +1382,16 @@ isLeavingLastConversationAdmin leavingUser conv = eligibleAdminFallbackMembers :: (Member BrigAPIAccess r) => Local ConvId -> - UserId -> + Maybe UserId -> StoredConversation -> Sem r [(Qualified UserId, User.Name)] -eligibleAdminFallbackMembers lcnv leavingUser conv = do - users <- Brig.getUsers (map (.id_) (filter ((/= leavingUser) . (.id_)) conv.localMembers)) +eligibleAdminFallbackMembers lcnv mLeavingUser conv = do + users <- Brig.getUsers (map (.id_) (filter ((/= mLeavingUser) . Just . (.id_)) conv.localMembers)) let usersById = Map.fromList [(User.userId u, u) | u <- users] pure [ (tUntagged (qualifyAs lcnv member.id_), u.userDisplayName) | member <- conv.localMembers, - member.id_ /= leavingUser, + Just member.id_ /= mLeavingUser, Just u <- [Map.lookup member.id_ usersById], isEligibleUser u ] @@ -1268,7 +1416,8 @@ deleteUserFromTeamConversationsImpl :: Member E.ExternalAccess r, Member Now r, Member Random r, - Member TeamSubsystem r + Member TeamSubsystem r, + Member JobSubsystem r ) => Local UserId -> Maybe ConnId -> @@ -1379,7 +1528,8 @@ removeMemberFromLocalConv :: Member (ErrorS ConvMemberNotFound) r, Member (Error AdminlessConversation) r, Member FeaturesConfigSubsystem r, - Member BrigAPIAccess r + Member BrigAPIAccess r, + Member JobSubsystem r ) => RemoveMemberResponseMode -> Local ConvId -> diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs new file mode 100644 index 00000000000..f022851ec67 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -0,0 +1,43 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.JobSubsystem + ( JobSubsystemConfig (..), + JobSubsystem (..), + scheduleAdminlessDeletionJob, + scheduleAdminlessReminderJob, + ) +where + +import Data.Id +import Data.Json.Util (UTCTimeMillis) +import Data.Qualified +import Data.Time.Clock (NominalDiffTime, UTCTime) +import Imports +import Polysemy + +data JobSubsystemConfig = JobSubsystemConfig + { jobSubsystemSchemaName :: Text + } + +data JobSubsystem m a where + ScheduleAdminlessDeletionJob :: Maybe (Local UserId) -> TeamId -> ConvId -> UTCTime -> JobSubsystem m () + ScheduleAdminlessReminderJob :: Maybe (Local UserId) -> TeamId -> ConvId -> UTCTimeMillis -> NominalDiffTime -> UTCTime -> JobSubsystem m () + +makeSem ''JobSubsystem diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs new file mode 100644 index 00000000000..69a33f35363 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs @@ -0,0 +1,202 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE TypeFamilies #-} + +-- | Adapter that lets Arbiter run against wire-server's shared Hasql pool. +-- +-- The pool we want to reuse is 'HasqlPoolExt.Pool'. Internally that is backed +-- by @Data.Pool (Either ConnectionError Connection)@, but the underlying +-- resource pool is intentionally opaque in @hasql-resource-pool@. The only +-- missing piece is therefore a small exported helper from that package that +-- borrows one live 'Connection' for the duration of a callback. +module Wire.JobSubsystem.ArbiterAdapter where + +import Arbiter.Core.Codec (Params, RowCodec) +import Arbiter.Core.Exceptions (throwInternal) +import Arbiter.Core.HasArbiterSchema (HasArbiterSchema (..)) +import Arbiter.Core.MonadArbiter (MonadArbiter (..)) +import Arbiter.Core.QueueRegistry (JobPayloadRegistry) +import Arbiter.Hasql.Decode qualified as Decode +import Arbiter.Hasql.Encode qualified as Encode +import Control.Exception (mask, onException, try) +import Control.Monad.Catch (MonadCatch, MonadMask, MonadThrow) +import Control.Monad.Reader +import Data.Misc (durationToCeilingSeconds) +import Data.Text qualified as T +import Hasql.Connection qualified as HasqlConn +import Hasql.Decoders qualified as Decoders +import Hasql.Encoders qualified as Encoders +import Hasql.Pool qualified as HasqlPool +import Hasql.Pool.Extended qualified as HasqlPoolExt +import Hasql.Session qualified as Session +import Hasql.Statement qualified as Statement +import Imports + +data WireArbiterEnv = WireArbiterEnv + { schemaName :: Text, + connectionPool :: HasqlPoolExt.Pool, + activeConn :: Maybe HasqlConn.Connection, + transactionDepth :: Int + } + +mkNewWireArbiterEnv :: Text -> HasqlPoolExt.Pool -> WireArbiterEnv +mkNewWireArbiterEnv schema pool = + WireArbiterEnv + { schemaName = schema, + connectionPool = pool, + activeConn = Nothing, + transactionDepth = 0 + } + +newtype WireArbiter (registry :: JobPayloadRegistry) a = WireArbiter + { unWireArbiter :: ReaderT WireArbiterEnv IO a + } + deriving newtype + ( Functor, + Applicative, + Monad, + MonadCatch, + MonadIO, + MonadMask, + MonadReader WireArbiterEnv, + MonadThrow, + MonadUnliftIO + ) + +runWireArbiter :: WireArbiterEnv -> WireArbiter registry a -> IO a +runWireArbiter env (WireArbiter action) = runReaderT action env + +instance HasArbiterSchema (WireArbiter registry) registry where + getSchema = asks schemaName + +instance MonadArbiter (WireArbiter registry) where + type Handler (WireArbiter registry) jobs result = HasqlConn.Connection -> jobs -> WireArbiter registry result + + executeQuery sql params codec = do + env <- ask + withConn env $ \conn -> + runQueryStatement False conn sql params codec + + executeQueryPrepared sql params codec = do + env <- ask + withConn env $ \conn -> + runQueryStatement True conn sql params codec + + executeStatement sql params = do + env <- ask + withConn env $ \conn -> + runExecStatement conn sql params + + withDbTransaction action = do + env <- ask + case activeConn env of + Nothing -> withRunInIO $ \run -> + run $ withPoolConnection env.connectionPool $ \conn -> run (beginCommitOrRollback conn action) + Just conn + | transactionDepth env <= 0 -> beginCommitOrRollback conn action + | otherwise -> beginSavepointTransaction conn action + + runHandlerWithConnection handler jobs = do + env <- ask + case activeConn env of + Just conn -> handler conn jobs + Nothing -> throwInternal "runHandlerWithConnection: no active connection" + +withConn :: WireArbiterEnv -> (HasqlConn.Connection -> IO a) -> WireArbiter registry a +withConn env f = + case activeConn env of + Just conn -> liftIO $ f conn + Nothing -> withPoolConnection env.connectionPool f + +-- | Borrow a live connection from wire-server's shared pool. +withPoolConnection :: HasqlPoolExt.Pool -> (HasqlConn.Connection -> IO a) -> WireArbiter registry a +withPoolConnection pool f = do + result <- + liftIO $ + HasqlPool.withConnectionWithPoolAcquisitionTimeout + (durationToCeilingSeconds pool.poolAcquisitionTimeout) + pool.rawPool + (fmap Right . f) + case result of + Right x -> pure x + Left HasqlPool.AcquisitionTimeoutUsageError -> do + liftIO $ HasqlPoolExt.recordHasqlPoolAcquisitionTimeout pool.metrics + throwInternal "hasql pool acquisition timeout" + Left (HasqlPool.ConnectionError err) -> do + liftIO $ HasqlPoolExt.recordHasqlPoolConnectionFailure pool.metrics + throwInternal $ "hasql connection error: " <> T.pack (show err) + Left (HasqlPool.SessionError err) -> do + liftIO $ HasqlPoolExt.recordHasqlPoolSessionFailure pool.metrics + throwInternal $ "hasql session error: " <> T.pack (show err) + +runQueryStatement :: Bool -> HasqlConn.Connection -> Text -> Params -> RowCodec a -> IO [a] +runQueryStatement prepare conn sql params codec = do + let mk = if prepare then Statement.preparable else Statement.unpreparable + stmt = + mk + (Encode.convertPlaceholders sql) + (Encode.buildEncoder params) + (Decode.hasqlRowDecoder codec) + result <- HasqlConn.use conn (Session.statement () stmt) + case result of + Right rows -> pure rows + Left err -> throwInternal $ "hasql query error: " <> T.pack (show err) + +runExecStatement :: HasqlConn.Connection -> Text -> Params -> IO Int64 +runExecStatement conn sql params = do + let stmt = Encode.buildStatementRowCount sql params + result <- HasqlConn.use conn (Session.statement () stmt) + case result of + Right n -> pure n + Left err -> throwInternal $ "hasql statement error: " <> T.pack (show err) + +runRawSql :: HasqlConn.Connection -> Text -> IO () +runRawSql conn sql = do + let stmt = Statement.unpreparable sql Encoders.noParams Decoders.noResult + result <- HasqlConn.use conn (Session.statement () stmt) + case result of + Right () -> pure () + Left err -> throwInternal $ "hasql sql error: " <> T.pack (show err) + +beginCommitOrRollback :: HasqlConn.Connection -> WireArbiter registry a -> WireArbiter registry a +beginCommitOrRollback conn action = do + withRunInIO $ \run -> + beginCommitOrRollbackIO conn $ + run $ + local + (\e -> e {activeConn = Just conn, transactionDepth = 1}) + action + +beginCommitOrRollbackIO :: HasqlConn.Connection -> IO a -> IO a +beginCommitOrRollbackIO conn action = mask $ \restore -> do + runRawSql conn "BEGIN" + result <- restore action `onException` rollbackSafely + runRawSql conn "COMMIT" + pure result + where + rollbackSafely = do + _ <- try (runRawSql conn "ROLLBACK") :: IO (Either SomeException ()) + pure () + +beginSavepointTransaction :: HasqlConn.Connection -> WireArbiter registry a -> WireArbiter registry a +beginSavepointTransaction conn action = do + env <- ask + let depth = transactionDepth env + withRunInIO $ \run -> + beginSavepointTransactionIO depth conn $ + run $ + local + (\e -> e {activeConn = Just conn, transactionDepth = depth + 1}) + action + +beginSavepointTransactionIO :: Int -> HasqlConn.Connection -> IO a -> IO a +beginSavepointTransactionIO depth conn action = mask $ \restore -> do + let spName = "arbiter_sp_" <> T.pack (show depth) + runRawSql conn ("SAVEPOINT " <> spName) + result <- + restore action + `onException` do + _ <- try (runRawSql conn ("ROLLBACK TO SAVEPOINT " <> spName)) :: IO (Either SomeException ()) + pure () + runRawSql conn ("RELEASE SAVEPOINT " <> spName) + pure result diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs new file mode 100644 index 00000000000..730d35f9214 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -0,0 +1,111 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeFamilies #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.JobSubsystem.Interpreter + ( interpretJobSubsystem, + ) +where + +import Arbiter.Core qualified as ArbiterCore +import Data.Id +import Data.Json.Util (UTCTimeMillis) +import Data.Qualified +import Data.Text qualified as Text +import Data.Time +import Hasql.Pool.Extended qualified as HasqlPoolExt +import Imports +import Polysemy +import Polysemy.Input (Input, input) +import Wire.API.Jobs +import Wire.JobSubsystem (JobSubsystem (..), JobSubsystemConfig (..)) +import Wire.JobSubsystem.ArbiterAdapter +import Wire.Postgres (PGConstraints) + +interpretJobSubsystem :: + (PGConstraints r, Member (Input RequestId) r) => + JobSubsystemConfig -> + InterpreterFor JobSubsystem r +interpretJobSubsystem conf = + interpret + \case + ScheduleAdminlessDeletionJob lusr tid cid scheduledFor -> scheduleAdminlessDeletionJob conf lusr tid cid scheduledFor + ScheduleAdminlessReminderJob lusr tid cid deletionScheduledFor reminderTimeout scheduledFor -> scheduleAdminlessReminderJob conf lusr tid cid deletionScheduledFor reminderTimeout scheduledFor + +scheduleAdminlessDeletionJob :: + forall r. + (PGConstraints r, Member (Input RequestId) r) => + JobSubsystemConfig -> + Maybe (Local UserId) -> + TeamId -> + ConvId -> + UTCTime -> + Sem r () +scheduleAdminlessDeletionJob JobSubsystemConfig {..} lusr teamId convId scheduledFor = do + requestId <- input @RequestId + pool <- input + let arbiterEnv = mkNewWireArbiterEnv jobSubsystemSchemaName pool + groupKey = "adminless-deletion:" <> idToText convId + arbiterJob = + ( ArbiterCore.defaultGroupedJob + groupKey + (AdminlessDeletion (AdminlessDeletionJob teamId convId (tUnqualified <$> lusr) requestId)) + ) + { ArbiterCore.notVisibleUntil = Just scheduledFor, + ArbiterCore.dedupKey = Just . ArbiterCore.IgnoreDuplicate $ adminlessJobDedupKey "deletion" convId, + ArbiterCore.maxAttempts = Just 3 + } + embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @(WireArbiter JobRegistry) @JobRegistry @ConversationsJobPayload arbiterJob + +scheduleAdminlessReminderJob :: + forall r. + (PGConstraints r, Member (Input RequestId) r) => + JobSubsystemConfig -> + Maybe (Local UserId) -> + TeamId -> + ConvId -> + UTCTimeMillis -> + NominalDiffTime -> + UTCTime -> + Sem r () +scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId deletionScheduledFor reminderTimeout scheduledFor = do + requestId <- input @RequestId + pool <- input @HasqlPoolExt.Pool + let arbiterEnv = mkNewWireArbiterEnv jobSubsystemSchemaName pool + groupKey = "adminless-reminder:" <> idToText convId + arbiterJob = + ( ArbiterCore.defaultGroupedJob + groupKey + (AdminlessReminder (AdminlessReminderJob teamId convId (tUnqualified <$> lusr) deletionScheduledFor requestId)) + ) + { ArbiterCore.notVisibleUntil = Just scheduledFor, + ArbiterCore.dedupKey = Just . ArbiterCore.IgnoreDuplicate $ adminlessReminderJobDedupKey convId reminderTimeout, + ArbiterCore.maxAttempts = Just 3 + } + embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @(WireArbiter JobRegistry) @JobRegistry @ConversationsJobPayload arbiterJob + +adminlessJobDedupKey :: Text -> ConvId -> Text +adminlessJobDedupKey jobType convId = + "adminless-" <> jobType <> ":" <> idToText convId + +adminlessReminderJobDedupKey :: ConvId -> NominalDiffTime -> Text +adminlessReminderJobDedupKey convId reminderTimeout = + adminlessJobDedupKey "reminder" convId <> ":" <> Text.pack (show reminderTimeout) diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs new file mode 100644 index 00000000000..bc7984738c4 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs @@ -0,0 +1,127 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE TypeApplications #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.JobSubsystem.Migrations + ( runJobMigrations, + ) +where + +import Arbiter.Migrations qualified as ArbiterMigrations +import Control.Exception (bracket, bracket_, throwIO) +import Data.Hashable qualified as Hashable +import Data.Proxy (Proxy (..)) +import Data.Secret (SecretText, revealSecretText) +import Data.Text qualified as T +import Data.Text.Encoding qualified as Text +import Hasql.Connection qualified as HasqlConnection +import Hasql.Connection.Settings qualified as HasqlConnectionSettings +import Hasql.Session qualified as HasqlSession +import Hasql.Statement qualified as HasqlStatement +import Hasql.TH +import Imports +import System.IO.Error (userError) +import System.Timeout (timeout) +import Wire.API.Jobs (JobRegistry) + +-- | Apply all migrations for the job registry before constructing any worker +-- pools or accepting jobs. +runJobMigrations :: SecretText -> Text -> IO () +runJobMigrations connStr schemaName = + withArbiterMigrationLock connStr schemaName $ do + result <- + ArbiterMigrations.runMigrationsForRegistry + (Proxy @JobRegistry) + (Text.encodeUtf8 $ revealSecretText connStr) + schemaName + ArbiterMigrations.defaultMigrationConfig + case result of + ArbiterMigrations.MigrationSuccess -> pure () + ArbiterMigrations.MigrationError err -> + throwIO . userError $ + "Arbiter migrations failed for schema " <> T.unpack schemaName <> ": " <> err + +-- | Serialize Arbiter schema migrations across all service instances that can +-- schedule or execute jobs. The lock is held on the same dedicated connection +-- for the whole migration because PostgreSQL advisory locks are session-scoped. +withArbiterMigrationLock :: SecretText -> Text -> IO a -> IO a +withArbiterMigrationLock connStr schemaName action = do + bracket acquireConnection HasqlConnection.release $ \lockConnection -> do + bracket_ + (acquireArbiterMigrationLockWithTimeout lockConnection) + (runAdvisoryLockStatement lockConnection releaseArbiterMigrationLock) + action + where + lockId :: Int64 + lockId = fromIntegral . Hashable.hash $ ("wire-server:arbiter-migrations:" <> schemaName :: Text) + + acquireArbiterMigrationLockWithTimeout :: HasqlConnection.Connection -> IO () + acquireArbiterMigrationLockWithTimeout connection = do + acquired <- timeout arbiterMigrationLockWaitTimeoutMicros retryUntilAcquired + case acquired of + Just () -> pure () + Nothing -> + throwIO . userError $ + "Timed out waiting for the Arbiter migration lock for schema " <> T.unpack schemaName + where + retryUntilAcquired :: IO () + retryUntilAcquired = do + acquired <- runAdvisoryLockStatement connection tryArbiterMigrationLock + if acquired + then pure () + else do + threadDelay arbiterMigrationLockRetryIntervalMicros + retryUntilAcquired + + arbiterMigrationLockRetryIntervalMicros :: Int + arbiterMigrationLockRetryIntervalMicros = 1_000_000 + + -- Do not let a stuck migration block service startup indefinitely. + arbiterMigrationLockWaitTimeoutMicros :: Int + arbiterMigrationLockWaitTimeoutMicros = 1 * 60 * 1_000_000 + + acquireConnection :: IO HasqlConnection.Connection + acquireConnection = do + connectionResult <- HasqlConnection.acquire . HasqlConnectionSettings.connectionString $ revealSecretText connStr + either + ( \err -> + throwIO . userError $ + "Failed to acquire PostgreSQL connection for Arbiter migration lock: " <> show err + ) + pure + connectionResult + + runAdvisoryLockStatement :: HasqlConnection.Connection -> HasqlStatement.Statement Int64 a -> IO a + runAdvisoryLockStatement connection statement = do + result <- HasqlConnection.use connection (HasqlSession.statement lockId statement) + either + ( \err -> + throwIO . userError $ + "Arbiter migration advisory lock query failed: " <> show err + ) + pure + result + + tryArbiterMigrationLock :: HasqlStatement.Statement Int64 Bool + tryArbiterMigrationLock = + [singletonStatement|SELECT (pg_try_advisory_lock($1 :: bigint) :: bool)|] + + releaseArbiterMigrationLock :: HasqlStatement.Statement Int64 () + releaseArbiterMigrationLock = + [resultlessStatement|SELECT (1 :: integer) FROM (SELECT pg_advisory_unlock($1 :: bigint))|] diff --git a/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs index 7e6b9ba959b..59565c35747 100644 --- a/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs @@ -65,7 +65,7 @@ interpretUserGroupSubsystem :: Member NotificationSubsystem r, Member TeamSubsystem r, Member GalleyAPIAccess r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => InterpreterFor UserGroupSubsystem r interpretUserGroupSubsystem = interpret $ \case @@ -113,7 +113,7 @@ createUserGroup :: Member (Input (Local ())) r, Member NotificationSubsystem r, Member TeamSubsystem r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => UserId -> NewUserGroup -> @@ -131,7 +131,7 @@ createUserGroupFullImpl :: Member NotificationSubsystem r, Member TeamSubsystem r, Member Random.Random r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => ManagedBy -> TeamId {- home team of the user group.-} -> @@ -368,7 +368,7 @@ addUser :: Member (Error UserGroupSubsystemError) r, Member NotificationSubsystem r, Member TeamSubsystem r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => UserId -> UserGroupId -> @@ -393,7 +393,7 @@ addUsers :: Member (Error UserGroupSubsystemError) r, Member NotificationSubsystem r, Member TeamSubsystem r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => UserId -> UserGroupId -> @@ -422,7 +422,7 @@ updateUsers :: Member (Error UserGroupSubsystemError) r, Member NotificationSubsystem r, Member TeamSubsystem r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => UserId -> UserGroupId -> @@ -438,7 +438,7 @@ updateUsersNoAccessControl :: Member NotificationSubsystem r, Member TeamSubsystem r, Member Random.Random r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => TeamId -> Maybe UserId -> @@ -463,7 +463,7 @@ removeUser :: Member (Error UserGroupSubsystemError) r, Member NotificationSubsystem r, Member TeamSubsystem r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => UserId -> UserGroupId -> @@ -525,7 +525,7 @@ updateChannels :: Member TeamSubsystem r, Member NotificationSubsystem r, Member GalleyAPIAccess r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => Bool -> UserId -> @@ -554,7 +554,7 @@ updateChannels appendOnly performer groupId channelIds = do triggerSyncUserGroup :: ( Member Random.Random r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => TeamId -> Maybe UserId -> @@ -562,7 +562,7 @@ triggerSyncUserGroup :: Sem r () triggerSyncUserGroup teamId actor userGroupId = do jobId <- Random.newId - publishJob jobId $ JobSyncUserGroup SyncUserGroup {..} + publishJob jobId $ BackgroundJobSyncUserGroup SyncUserGroup {..} resetUserGroupInternal :: ( Member Store.UserGroupStore r, @@ -570,7 +570,7 @@ resetUserGroupInternal :: Member TeamSubsystem r, Member NotificationSubsystem r, Member Random.Random r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => UpdateGroupInternalRequest -> Sem r () diff --git a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs index 34133bf4475..bd6689d97e6 100644 --- a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs @@ -48,6 +48,7 @@ import Wire.ConversationSubsystem.Update (removeMemberQualified) import Wire.ExternalAccess (ExternalAccess (..)) import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem (..)) import Wire.FederationAPIAccess (FederationAPIAccess (..)) +import Wire.JobSubsystem (JobSubsystem (..)) import Wire.MockInterpreters.Now (defaultTime, interpretNowConst) import Wire.MockInterpreters.TinyLog (noopLogger) import Wire.NotificationSubsystem (NotificationSubsystem (..)) @@ -121,6 +122,7 @@ spec = describe "ConversationSubsystem.Interpreter" do . interpretNotificationSubsystem . interpretProposalStore . interpretTeamSubsystem + . interpretJobSubsystem . interpretNowConst defaultTime . interpretRandom . noopLogger @@ -260,6 +262,16 @@ interpretTeamSubsystem = interpret $ \case _ -> error "unexpected TeamSubsystem call in test" +interpretJobSubsystem :: + Sem (JobSubsystem ': r) a -> + Sem r a +interpretJobSubsystem = + interpret $ \case + ScheduleAdminlessDeletionJob {} -> + pure () + ScheduleAdminlessReminderJob {} -> + pure () + interpretRandom :: Sem (Random ': r) a -> Sem r a diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BackgroundJobPublisher.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BackgroundJobPublisher.hs index ba7824f4440..8594a28f2b7 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BackgroundJobPublisher.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BackgroundJobPublisher.hs @@ -19,8 +19,8 @@ module Wire.MockInterpreters.BackgroundJobPublisher where import Imports import Polysemy -import Wire.BackgroundJobsPublisher (BackgroundJobsPublisher (..)) +import Wire.BackgroundJobsPublisher (BackgroundJobPublisher (..)) -noopBackgroundJobsPublisher :: InterpreterFor BackgroundJobsPublisher r -noopBackgroundJobsPublisher = interpret $ \case +noopBackgroundJobPublisher :: InterpreterFor BackgroundJobPublisher r +noopBackgroundJobPublisher = interpret $ \case PublishJob {} -> pure () diff --git a/libs/wire-subsystems/test/unit/Wire/UserGroupSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserGroupSubsystem/InterpreterSpec.hs index 404c0931af3..355b21891eb 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserGroupSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserGroupSubsystem/InterpreterSpec.hs @@ -80,7 +80,7 @@ type AllDependencies = `Append` '[ Input (Local ()), MockNow, NotificationSubsystem, - BackgroundJobsPublisher.BackgroundJobsPublisher, + BackgroundJobsPublisher.BackgroundJobPublisher, State [Push], Random.Random, Error UserGroupSubsystemError, @@ -114,7 +114,7 @@ interpretDependencies :: interpretDependencies initialUsers initialTeams = Random.randomToNull . evalState mempty - . noopBackgroundJobsPublisher + . noopBackgroundJobPublisher . inMemoryNotificationSubsystemInterpreter . evalState defaultTime . runInputConst (toLocalUnsafe (Domain "example.com") ()) @@ -133,7 +133,7 @@ runDependenciesWithReturnState initialUsers initialTeams = . runLocalErrors . Random.randomToNull . runState mempty - . noopBackgroundJobsPublisher + . noopBackgroundJobPublisher . inMemoryNotificationSubsystemInterpreter . evalState defaultTime . runInputConst (toLocalUnsafe (Domain "example.com") ()) diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 9ee955bcaf3..2479b2e23e1 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -91,6 +91,9 @@ common common-all , amazonka-ses , amazonka-sqs , amqp + , arbiter-core + , arbiter-hasql + , arbiter-migrations , async , attoparsec , base @@ -109,6 +112,7 @@ common common-all , contravariant , cookie , cql + , cron , crypton , crypton-asn1-encoding , crypton-asn1-types @@ -363,6 +367,10 @@ library Wire.InternalEvent Wire.InvitationStore Wire.InvitationStore.Cassandra + Wire.JobSubsystem + Wire.JobSubsystem.ArbiterAdapter + Wire.JobSubsystem.Interpreter + Wire.JobSubsystem.Migrations Wire.LegalHold Wire.LegalHoldStore Wire.LegalHoldStore.Cassandra diff --git a/nix/haskell-pins.nix b/nix/haskell-pins.nix index 17849cbc878..07b0f2c3fa3 100644 --- a/nix/haskell-pins.nix +++ b/nix/haskell-pins.nix @@ -58,6 +58,18 @@ let # END maintained by us # -------------------- + arbiter = { + src = inputs.arbiter; + packages = { + arbiter-core = "arbiter-core"; + arbiter-hasql = "arbiter-hasql"; + arbiter-migrations = "arbiter-migrations"; + arbiter-simple = "arbiter-simple"; + arbiter-test-common = "arbiter-test-common"; + arbiter-worker = "arbiter-worker"; + }; + }; + bloodhound = { src = inputs.bloodhound; }; diff --git a/nix/manual-overrides.nix b/nix/manual-overrides.nix index 59373e631c3..7ace4edb661 100644 --- a/nix/manual-overrides.nix +++ b/nix/manual-overrides.nix @@ -32,6 +32,11 @@ hself: hsuper: { hasql-migration = hlib.markUnbroken (hlib.doJailbreak (hlib.dontCheck hsuper.hasql-migration)); hasql-transaction = hlib.dontCheck hsuper.hasql-transaction_1_2_2; postgresql-binary = hlib.dontCheck (hsuper.postgresql-binary_0_15_0_1); + monad-logger-aeson = hlib.markUnbroken (hlib.dontCheck hsuper.monad-logger-aeson); + # Integration tests require a PostgreSQL server on localhost:5432. + arbiter-hasql = hlib.dontCheck hsuper.arbiter-hasql; + arbiter-simple = hlib.dontCheck hsuper.arbiter-simple; + arbiter-worker = hlib.dontCheck hsuper.arbiter-worker; # Test fixtures don't seem to be bundled for Hackage hsaml2 = hlib.dontCheck (hsuper.hsaml2); diff --git a/services/background-worker/background-worker.cabal b/services/background-worker/background-worker.cabal index af6b66a8ffe..a43796a4ca7 100644 --- a/services/background-worker/background-worker.cabal +++ b/services/background-worker/background-worker.cabal @@ -12,6 +12,7 @@ build-type: Simple library -- cabal-fmt: expand src exposed-modules: + Wire.AdminlessJobsWorker Wire.BackendNotificationPusher Wire.BackgroundWorker Wire.BackgroundWorker.Env @@ -20,6 +21,7 @@ library Wire.BackgroundWorker.Jobs.Registry Wire.BackgroundWorker.Options Wire.BackgroundWorker.Util + Wire.BackgroundWorker.Workers Wire.DeadUserNotificationWatcher Wire.Effects Wire.MeetingsCleanupWorker @@ -35,6 +37,8 @@ library build-depends: aeson , amqp + , arbiter-core + , arbiter-worker , base , bilge , bytestring diff --git a/services/background-worker/background-worker.integration.yaml b/services/background-worker/background-worker.integration.yaml index 97734fccd3b..e264ce14016 100644 --- a/services/background-worker/background-worker.integration.yaml +++ b/services/background-worker/background-worker.integration.yaml @@ -65,6 +65,21 @@ backgroundJobs: jobTimeout: 5s maxAttempts: 3 +# Job dispatcher configuration for integration +jobs: + pollInterval: 1s # Poll every second so due jobs are discovered promptly in tests + workerThreads: 1 # Keep one worker per job queue in integration tests + visibilityTimeout: 60s + jobHeartbeatInterval: 30s + workerHeartbeatInterval: 10s + backoffBase: 2.0 + backoffCap: 86400s + jitter: equal + gracefulShutdownTimeout: 30s + reaperInterval: 300s + reaperTimeout: 300s + workerStaleThreshold: 300s + # Meetings cleanup configuration for integration meetingsCleanup: cleanOlderThanHours: 0.0014 # Clean meetings older than ~5 seconds diff --git a/services/background-worker/default.nix b/services/background-worker/default.nix index 28faab430e9..acf79b6e195 100644 --- a/services/background-worker/default.nix +++ b/services/background-worker/default.nix @@ -5,6 +5,8 @@ { mkDerivation , aeson , amqp +, arbiter-core +, arbiter-worker , base , bilge , bytestring @@ -66,6 +68,8 @@ mkDerivation { libraryHaskellDepends = [ aeson amqp + arbiter-core + arbiter-worker base bilge bytestring diff --git a/services/background-worker/src/Wire/AdminlessJobsWorker.hs b/services/background-worker/src/Wire/AdminlessJobsWorker.hs new file mode 100644 index 00000000000..23fe0906440 --- /dev/null +++ b/services/background-worker/src/Wire/AdminlessJobsWorker.hs @@ -0,0 +1,83 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +module Wire.AdminlessJobsWorker + ( runAdminlessDeletionJob, + runAdminlessReminderJob, + ) +where + +import Arbiter.Core.Exceptions (throwRetryable) +import Arbiter.Core.Job.Types (JobRead, notVisibleUntil, payload) +import Data.Qualified (toLocalUnsafe) +import Imports +import System.Logger qualified as Log +import Wire.API.Jobs (AdminlessDeletionJob (..), AdminlessReminderJob (..)) +import Wire.BackgroundWorker.Env (AppT, Env (..)) +import Wire.ConversationSubsystem +import Wire.Effects (runBackgroundWorkerEffects) +import Wire.ExternalAccess.External (ExtEnv) + +runAdminlessDeletionJob :: ExtEnv -> JobRead AdminlessDeletionJob -> AppT IO () +runAdminlessDeletionJob extEnv job = do + env <- ask + Log.debug env.logger $ + Log.msg (Log.val "Running adminless deletion job") + . Log.field "team_id" (show job.payload.adminlessDeletionJobTeamId) + . Log.field "conversation_id" (show job.payload.adminlessDeletionJobConversationId) + . Log.field "orig_user_id" (show job.payload.adminlessDeletionJobOrigUserId) + . Log.field "request_id" (show job.payload.adminlessDeletionJobRequestId) + . Log.field "scheduled_for" (show job.notVisibleUntil) + result <- + liftIO $ + runBackgroundWorkerEffects env extEnv job.payload.adminlessDeletionJobRequestId Nothing $ + do + Log.debug env.logger $ + Log.msg (Log.val "Adminless deletion job: invoking conversation delete") + . Log.field "team_id" (show job.payload.adminlessDeletionJobTeamId) + . Log.field "conversation_id" (show job.payload.adminlessDeletionJobConversationId) + internalDeleteLocalAdminlessGroup + (toLocalUnsafe env.federationDomain <$> job.payload.adminlessDeletionJobOrigUserId) + (toLocalUnsafe env.federationDomain job.payload.adminlessDeletionJobConversationId) + Log.debug env.logger $ + Log.msg (Log.val "Adminless deletion job finished") + . Log.field "team_id" (show job.payload.adminlessDeletionJobTeamId) + . Log.field "conversation_id" (show job.payload.adminlessDeletionJobConversationId) + either (liftIO . throwRetryable) pure result + +runAdminlessReminderJob :: ExtEnv -> JobRead AdminlessReminderJob -> AppT IO () +runAdminlessReminderJob extEnv job = do + env <- ask + Log.debug env.logger $ + Log.msg (Log.val "Running adminless reminder job") + . Log.field "team_id" (show job.payload.adminlessReminderJobTeamId) + . Log.field "conversation_id" (show job.payload.adminlessReminderJobConversationId) + . Log.field "request_id" (show job.payload.adminlessReminderJobRequestId) + . Log.field "deletion_scheduled_for" (show job.payload.adminlessReminderJobDeletionScheduledFor) + result <- + liftIO $ + runBackgroundWorkerEffects env extEnv job.payload.adminlessReminderJobRequestId Nothing $ + do + internalNotifyAdminlessReminder + (toLocalUnsafe env.federationDomain <$> job.payload.adminlessReminderJobOrigUserId) + (toLocalUnsafe env.federationDomain job.payload.adminlessReminderJobConversationId) + job.payload.adminlessReminderJobDeletionScheduledFor + Log.debug env.logger $ + Log.msg (Log.val "Adminless reminder job finished") + . Log.field "team_id" (show job.payload.adminlessReminderJobTeamId) + . Log.field "conversation_id" (show job.payload.adminlessReminderJobConversationId) + either (liftIO . throwRetryable) pure result diff --git a/services/background-worker/src/Wire/BackgroundWorker.hs b/services/background-worker/src/Wire/BackgroundWorker.hs index fd145cf4668..b57ba12df40 100644 --- a/services/background-worker/src/Wire/BackgroundWorker.hs +++ b/services/background-worker/src/Wire/BackgroundWorker.hs @@ -33,8 +33,8 @@ import Wire.BackgroundWorker.Env import Wire.BackgroundWorker.Health qualified as Health import Wire.BackgroundWorker.Jobs.Consumer qualified as Jobs import Wire.BackgroundWorker.Options +import Wire.BackgroundWorker.Workers as Workers import Wire.DeadUserNotificationWatcher qualified as DeadUserNotificationWatcher -import Wire.MeetingsCleanupWorker qualified as MeetingsCleanupWorker import Wire.Options.Galley qualified as Galley import Wire.PostgresMigrations qualified as Migrations @@ -82,10 +82,10 @@ run opts galleyOpts = do runAppT env $ withNamedLogger "background-job-consumer" $ Jobs.startWorker amqpEP - cleanupMeetings <- + cleanupJobRunner <- runAppT env $ - withNamedLogger "meetings-cleanup" $ - MeetingsCleanupWorker.startWorker opts.meetingsCleanup + withNamedLogger "job-runner" $ + Workers.startWorker opts.jobs opts.meetingsCleanup let cleanup = void $ runConcurrently $ @@ -96,8 +96,8 @@ run opts galleyOpts = do <*> Concurrently cleanUpConvCodesMigration <*> Concurrently cleanupTeamFeaturesMigration <*> Concurrently cleanupDomainRegistrationMigration + <*> Concurrently cleanupJobRunner <*> Concurrently cleanupJobs - <*> Concurrently cleanupMeetings let server = defaultServer (T.unpack opts.backgroundWorker.host) opts.backgroundWorker.port env.logger let settings = newSettings server diff --git a/services/background-worker/src/Wire/BackgroundWorker/Env.hs b/services/background-worker/src/Wire/BackgroundWorker/Env.hs index 2bccadee873..bc904921a4f 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Env.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Env.hs @@ -29,6 +29,7 @@ import Data.Domain (Domain) import Data.Id (TeamId) import Data.Map.Strict qualified as Map import Data.Misc (HttpsUrl) +import Data.Secret (SecretText) import HTTP2.Client.Manager import Hasql.Pool.Extended import Hasql.Pool.Extended qualified as Hasql @@ -89,6 +90,8 @@ data Env = Env cassandraGalley :: ClientState, cassandraBrig :: ClientState, hasqlPool :: Hasql.Pool, + -- May contain the PostgreSQL password. Do not unwrap outside the Arbiter boundary. + arbiterConnStr :: SecretText, -- Dedicated AMQP channels per concern amqpJobsPublisherChannel :: MVar Q.Channel, amqpBackendNotificationsChannel :: MVar Q.Channel, @@ -190,6 +193,7 @@ mkEnv opts galleyOpts = do checkGroupInfo = galleyOpts._settings._checkGroupInfo workerRunningGauge <- mkWorkerRunningGauge hasqlPool <- initPostgresPool opts.postgresqlPool galleyOpts._postgresql galleyOpts._postgresqlPassword + arbiterConnStr <- postgresqlConnectionStringWithPassword galleyOpts._postgresql galleyOpts._postgresqlPassword Log.info logger $ Log.msg @Text "Opening RabbitMQ channel: background-worker-jobs-publisher..." amqpJobsPublisherChannel <- mkRabbitMqChannelMVar logger (Just "background-worker-jobs-publisher") $ diff --git a/services/background-worker/src/Wire/BackgroundWorker/Jobs/Consumer.hs b/services/background-worker/src/Wire/BackgroundWorker/Jobs/Consumer.hs index dbd697c5bef..cb0d8a4b9c6 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Jobs/Consumer.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Jobs/Consumer.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.BackgroundWorker.Jobs.Consumer (startWorker, BackgroundJobsMetrics (..)) where +module Wire.BackgroundWorker.Jobs.Consumer (startWorker, BackgroundJobMetrics (..)) where import Control.Concurrent.Timeout qualified as Timeout import Control.Retry @@ -39,7 +39,7 @@ import Wire.BackgroundWorker.Jobs.Registry import Wire.BackgroundWorker.Options import Wire.BackgroundWorker.Util (CleanupAction) -data BackgroundJobsMetrics = BackgroundJobsMetrics +data BackgroundJobMetrics = BackgroundJobMetrics { workersBusy :: Gauge, concurrencyConfigured :: Gauge, jobsReceived :: Vector Text Counter, @@ -51,7 +51,7 @@ data BackgroundJobsMetrics = BackgroundJobsMetrics jobDuration :: Vector Text Histogram } -mkMetrics :: IO BackgroundJobsMetrics +mkMetrics :: IO BackgroundJobMetrics mkMetrics = do workersBusy <- register (gauge $ Info {metricName = "wire_background_jobs_workers_busy", metricHelp = "In-flight background jobs"}) concurrencyConfigured <- register (gauge $ Info {metricName = "wire_background_jobs_concurrency_configured", metricHelp = "Configured concurrency for this process"}) @@ -62,7 +62,7 @@ mkMetrics = do jobsInvalid <- register (vector "job_type" $ counter $ Info "wire_background_jobs_invalid_total" "Invalid jobs received") jobsRedelivered <- register (vector "job_type" $ counter $ Info "wire_background_jobs_redelivered_total" "Jobs marked redelivered by broker") jobDuration <- register (vector "job_type" $ histogram (Info "wire_background_jobs_duration_seconds" "Job duration seconds") defaultBuckets) - pure BackgroundJobsMetrics {..} + pure BackgroundJobMetrics {..} startWorker :: AmqpEndpoint -> AppT IO CleanupAction startWorker rabbitmqOpts = do @@ -107,16 +107,16 @@ startWorker rabbitmqOpts = do Log.info $ Log.msg (Log.val "Background job consumer cleanup") markAsNotWorking BackgroundJobConsumer -handleDelivery :: BackgroundJobsMetrics -> BackgroundJobsConfig -> (Q.Message, Q.Envelope) -> AppT IO () +handleDelivery :: BackgroundJobMetrics -> BackgroundJobsConfig -> (Q.Message, Q.Envelope) -> AppT IO () handleDelivery metrics cfg (msg, env) = do - case Aeson.eitherDecode @Job (Q.msgBody msg) of + case Aeson.eitherDecode @BackgroundJob (Q.msgBody msg) of Left err -> do withLabel metrics.jobsInvalid "invalid" incCounter Log.err $ Log.msg (Log.val "Invalid background job JSON") . Log.field "error" err Timeout.threadDelay (200 # MilliSecond) -- avoid tight redelivery loop liftIO $ Q.rejectEnv env True Right job -> do - let lbl = jobPayloadLabel job.payload + let lbl = backgroundJobPayloadLabel job.payload when (Q.envRedelivered env) $ withLabel metrics.jobsRedelivered lbl incCounter withLabel metrics.jobsReceived lbl incCounter UnliftIO.bracket_ (incGauge metrics.workersBusy) (decGauge metrics.workersBusy) $ do @@ -130,7 +130,7 @@ handleDelivery metrics cfg (msg, env) = do Log.err $ Log.msg (Log.val "Background job failed after retries") . Log.field "error" e liftIO $ Q.rejectEnv env False where - runAttempts :: Text -> Job -> AppT IO (Either Text ()) + runAttempts :: Text -> BackgroundJob -> AppT IO (Either Text ()) runAttempts lbl job = do let retries = max 0 (fromRange cfg.maxAttempts - 1) policy = limitRetries retries <> fullJitterBackoff 100000 -- 100ms base diff --git a/services/background-worker/src/Wire/BackgroundWorker/Jobs/Registry.hs b/services/background-worker/src/Wire/BackgroundWorker/Jobs/Registry.hs index 7e4bbcc0648..abb737a95a0 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Jobs/Registry.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Jobs/Registry.hs @@ -21,21 +21,21 @@ module Wire.BackgroundWorker.Jobs.Registry where import Imports -import Wire.API.BackgroundJobs (Job (..)) -import Wire.BackgroundJobsPublisher.RabbitMQ (interpretBackgroundJobsPublisherRabbitMQ) +import Wire.API.BackgroundJobs (BackgroundJob (..)) +import Wire.BackgroundJobsPublisher.RabbitMQ (interpretBackgroundJobPublisherRabbitMQ) import Wire.BackgroundJobsRunner (runJob) -import Wire.BackgroundJobsRunner.Interpreter hiding (runJob) +import Wire.BackgroundJobsRunner.Interpreter (interpretBackgroundJobRunner) import Wire.BackgroundWorker.Env (AppT, Env (..)) import Wire.Effects import Wire.ExternalAccess.External -dispatchJob :: Job -> AppT IO (Either Text ()) +dispatchJob :: BackgroundJob -> AppT IO (Either Text ()) dispatchJob job = do env <- ask @Env let disableTlsV1 = True extEnv <- liftIO (initExtEnv disableTlsV1) liftIO $ runBackgroundWorkerEffects env extEnv job.requestId (Just job.jobId) - . interpretBackgroundJobsPublisherRabbitMQ job.requestId env.amqpJobsPublisherChannel - . interpretBackgroundJobsRunner + . interpretBackgroundJobPublisherRabbitMQ job.requestId env.amqpJobsPublisherChannel + . interpretBackgroundJobRunner $ runJob job diff --git a/services/background-worker/src/Wire/BackgroundWorker/Options.hs b/services/background-worker/src/Wire/BackgroundWorker/Options.hs index f89c52e8300..61df5d5d14f 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Options.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Options.hs @@ -22,7 +22,7 @@ module Wire.BackgroundWorker.Options where import Data.Aeson import Data.Aeson.Types (JSONPathElement (Key), parserThrowError) import Data.Misc -import Data.Range (Range) +import Data.Range (Range, unsafeRange) import GHC.Generics import Hasql.Pool.Extended import Imports @@ -55,6 +55,7 @@ data Opts = Opts migrateConversationCodes :: !Bool, migrateTeamFeatures :: !Bool, migrateDomainRegistration :: !Bool, + jobs :: JobConfig, meetingsCleanup :: MeetingsCleanupConfig, backgroundJobs :: BackgroundJobsConfig } @@ -99,6 +100,83 @@ data BackgroundJobsConfig = BackgroundJobsConfig deriving (Show, Generic) deriving (FromJSON) via Generically BackgroundJobsConfig +data JobConfig = JobConfig + { -- | Arbiter dispatcher poll interval for jobs. + -- Lower values reduce discovery latency for due jobs. + pollInterval :: Duration, + -- | Number of worker threads in each job queue. + workerThreads :: Range 1 1000 Int, + -- | How long a claimed job remains invisible while it is processed. + visibilityTimeout :: Duration, + -- | How often a running job refreshes its visibility timeout. + jobHeartbeatInterval :: Duration, + -- | How often a worker refreshes its own heartbeat. + workerHeartbeatInterval :: Duration, + -- | Base used by Arbiter's exponential retry backoff. + backoffBase :: Double, + -- | Upper bound for Arbiter's exponential retry backoff. + backoffCap :: Duration, + -- | Jitter mode used for retry delays. + jitter :: JobJitter, + -- | Maximum time to wait for in-flight jobs during shutdown. + -- 'Nothing' waits indefinitely. + gracefulShutdownTimeout :: Maybe Duration, + -- | How often the Arbiter reaper runs. + reaperInterval :: Duration, + -- | Maximum time allowed for one reaper pass. + reaperTimeout :: Duration, + -- | How old a worker heartbeat may be before it is considered stale. + workerStaleThreshold :: Duration + } + deriving (Show, Generic) + +data JobJitter + = JobNoJitter + | JobFullJitter + | JobEqualJitter + deriving (Eq, Show, Generic) + +instance FromJSON JobJitter where + parseJSON = withText "JobJitter" $ \case + "none" -> pure JobNoJitter + "full" -> pure JobFullJitter + "equal" -> pure JobEqualJitter + _ -> fail "expected one of: none, full, equal" + +instance FromJSON JobConfig where + parseJSON = + withObject "JobConfig" $ \o -> do + pollInterval <- o .:? "pollInterval" .!= unsafeParseDuration "5s" + workerThreads <- o .:? "workerThreads" .!= unsafeRange 1 + visibilityTimeout <- o .:? "visibilityTimeout" .!= unsafeParseDuration "60s" + jobHeartbeatInterval <- o .:? "jobHeartbeatInterval" .!= unsafeParseDuration "30s" + workerHeartbeatInterval <- o .:? "workerHeartbeatInterval" .!= unsafeParseDuration "10s" + backoffBase <- o .:? "backoffBase" .!= 2.0 + backoffCap <- o .:? "backoffCap" .!= unsafeParseDuration "86400s" + jitter <- o .:? "jitter" .!= JobEqualJitter + gracefulShutdownTimeout <- + o .:? "gracefulShutdownTimeout" .!= Just (unsafeParseDuration "30s") + reaperInterval <- o .:? "reaperInterval" .!= unsafeParseDuration "300s" + reaperTimeout <- o .:? "reaperTimeout" .!= unsafeParseDuration "300s" + workerStaleThreshold <- o .:? "workerStaleThreshold" .!= unsafeParseDuration "300s" + let validatePositive key value = + when (duration value <= 0) $ + parserThrowError [Key key] $ + show key <> " must be greater than 0, got: " <> show value + validatePositive "pollInterval" pollInterval + validatePositive "visibilityTimeout" visibilityTimeout + validatePositive "jobHeartbeatInterval" jobHeartbeatInterval + validatePositive "workerHeartbeatInterval" workerHeartbeatInterval + validatePositive "backoffCap" backoffCap + validatePositive "reaperInterval" reaperInterval + validatePositive "reaperTimeout" reaperTimeout + validatePositive "workerStaleThreshold" workerStaleThreshold + for_ gracefulShutdownTimeout $ validatePositive "gracefulShutdownTimeout" + when (backoffBase <= 0) $ + parserThrowError [Key "backoffBase"] $ + "backoffBase must be greater than 0, got: " <> show backoffBase + pure JobConfig {..} + data MeetingsCleanupConfig = MeetingsCleanupConfig { -- | Delete meetings older than this many hours cleanOlderThanHours :: Double, diff --git a/services/background-worker/src/Wire/BackgroundWorker/Workers.hs b/services/background-worker/src/Wire/BackgroundWorker/Workers.hs new file mode 100644 index 00000000000..5ff16810883 --- /dev/null +++ b/services/background-worker/src/Wire/BackgroundWorker/Workers.hs @@ -0,0 +1,316 @@ +{-# LANGUAGE ConstraintKinds #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeApplications #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.BackgroundWorker.Workers (startWorker) where + +import Arbiter.Core qualified as ArbiterCore +import Arbiter.Core.Job.Types (JobRead, RegistryAdmissionPolicies) +import Arbiter.Core.QueueRegistry (RegistryTables, TableForPayload) +import Arbiter.Worker qualified as ArbiterWorker +import Arbiter.Worker.Config qualified as ArbiterWorkerConfig +import Arbiter.Worker.Cron qualified as ArbiterWorkerCron +import Control.Exception (throwIO) +import Data.Misc (Duration, duration) +import Data.Proxy (Proxy (..)) +import Data.Range (fromRange) +import Data.Secret (SecretText, revealSecretText) +import Data.Text qualified as T +import Data.Text.Encoding qualified as Text +import Data.Time.Clock (NominalDiffTime) +import GHC.TypeLits (KnownSymbol) +import Imports +import System.Cron (CronSchedule, serializeCronSchedule) +import System.IO.Error (userError) +import System.Logger qualified as Log +import UnliftIO.Async qualified as Async +import Wire.API.Jobs +import Wire.AdminlessJobsWorker (runAdminlessDeletionJob, runAdminlessReminderJob) +import Wire.BackgroundWorker.Env (AppT, Env (..), runAppT) +import Wire.BackgroundWorker.Options (JobConfig (..), JobJitter (..), MeetingsCleanupConfig (..)) +import Wire.BackgroundWorker.Util +import Wire.ExternalAccess.External +import Wire.JobSubsystem.ArbiterAdapter +import Wire.JobSubsystem.Migrations (runJobMigrations) +import Wire.MeetingsCleanupWorker + +-- | Runtime settings shared by every job runner in a process. +-- +-- These values deliberately mirror the Arbiter worker defaults that we use. +-- Keeping them in one record ensures all job types use the same +-- execution policy as settings are added or tuned. +data JobWorkerSettings = JobWorkerSettings + { jobWorkerThreads :: Int, + jobPollInterval :: NominalDiffTime, + jobVisibilityTimeout :: NominalDiffTime, + jobHeartbeatInterval :: NominalDiffTime, + jobWorkerHeartbeatInterval :: NominalDiffTime, + jobBackoffBase :: Double, + jobBackoffCap :: NominalDiffTime, + jobJitter :: ArbiterWorker.Jitter, + jobGracefulShutdownTimeout :: Maybe NominalDiffTime, + jobReaperInterval :: NominalDiffTime, + jobReaperTimeout :: NominalDiffTime, + jobWorkerStaleThreshold :: NominalDiffTime + } + +data JobRunnerConfig registry = JobRunnerConfig + { jobRunnerLogger :: Log.Logger, + jobRunnerSchedule :: CronSchedule, + -- May contain the PostgreSQL password. Keep it wrapped until the Arbiter boundary. + jobRunnerArbiterConnStr :: SecretText, + jobRunnerSchemaName :: Text, + jobRunnerSettings :: JobWorkerSettings + } + +startWorker :: JobConfig -> MeetingsCleanupConfig -> AppT IO CleanupAction +startWorker scheduledConfig meetingsCleanupConfig = do + env <- ask + extEnv <- liftIO $ initExtEnv True + let cleanupConfig = + CleanupConfig + { retentionHours = meetingsCleanupConfig.cleanOlderThanHours, + batchSize = meetingsCleanupConfig.batchSize + } + workerSettings = + JobWorkerSettings + { jobWorkerThreads = fromRange scheduledConfig.workerThreads, + jobPollInterval = jobDuration scheduledConfig.pollInterval, + jobVisibilityTimeout = jobDuration scheduledConfig.visibilityTimeout, + jobHeartbeatInterval = jobDuration scheduledConfig.jobHeartbeatInterval, + jobWorkerHeartbeatInterval = jobDuration scheduledConfig.workerHeartbeatInterval, + jobBackoffBase = scheduledConfig.backoffBase, + jobBackoffCap = jobDuration scheduledConfig.backoffCap, + jobJitter = toJobJitter scheduledConfig.jitter, + jobGracefulShutdownTimeout = fmap jobDuration scheduledConfig.gracefulShutdownTimeout, + jobReaperInterval = jobDuration scheduledConfig.reaperInterval, + jobReaperTimeout = jobDuration scheduledConfig.reaperTimeout, + jobWorkerStaleThreshold = jobDuration scheduledConfig.workerStaleThreshold + } + workersConfig = + JobRunnerConfig + { jobRunnerLogger = env.logger, + jobRunnerSchedule = meetingsCleanupConfig.schedule, + -- Arbiter still uses the connection string for LISTEN/NOTIFY. + -- The actual job DB access goes through the shared Hasql pool + -- passed from the background-worker environment. + jobRunnerArbiterConnStr = env.arbiterConnStr, + jobRunnerSchemaName = ArbiterCore.defaultSchemaName, + jobRunnerSettings = workerSettings + } :: + JobRunnerConfig JobRegistry + liftIO $ runJobMigrations env.arbiterConnStr ArbiterCore.defaultSchemaName + liftIO $ runJobRunner env extEnv workersConfig cleanupConfig + +jobDuration :: Duration -> NominalDiffTime +jobDuration = realToFrac . duration + +toJobJitter :: JobJitter -> ArbiterWorker.Jitter +toJobJitter = \case + JobNoJitter -> ArbiterWorker.NoJitter + JobFullJitter -> ArbiterWorker.FullJitter + JobEqualJitter -> ArbiterWorker.EqualJitter + +-- | Start the worker pools for the job queues. +-- +-- Each domain queue has its own Arbiter table and worker pool. The meetings +-- pool owns the recurring cleanup cron job, while the conversations pool owns +-- the adminless one-off jobs. Both pools are supervised by Arbiter's +-- multi-pool runner, so they share the process lifecycle without sharing a +-- payload type or queue. +runJobRunner :: + forall registry. + ( RegistryTables registry, + RegistryAdmissionPolicies registry, + KnownSymbol (TableForPayload MeetingsJobPayload registry), + KnownSymbol (TableForPayload ConversationsJobPayload registry) + ) => + Env -> + ExtEnv -> + JobRunnerConfig registry -> + CleanupConfig -> + IO (IO ()) +runJobRunner env extEnv runnerConfig cleanupConfig = do + let arbiterConnStr = Text.encodeUtf8 (revealSecretText runnerConfig.jobRunnerArbiterConnStr) + Log.info runnerConfig.jobRunnerLogger $ + Log.msg (Log.val "Starting job worker") + . Log.field "queue_names" (T.intercalate "," [meetingsQueueName, conversationsQueueName]) + . Log.field "schedule" (show runnerConfig.jobRunnerSchedule) + + let arbiterEnv = mkNewWireArbiterEnv runnerConfig.jobRunnerSchemaName env.hasqlPool + meetingsWorkerHandler _conn job = liftIO $ do + Log.info runnerConfig.jobRunnerLogger $ + Log.msg (Log.val "Running job") + . Log.field "queue_name" meetingsQueueName + . Log.field "payload_type" (meetingsJobPayloadTypeName job.payload) + case job.payload of + MeetingsCleanup _ -> runAppT env $ runCleanupOldMeetings cleanupConfig + + conversationsWorkerHandler _conn job = liftIO $ do + Log.info runnerConfig.jobRunnerLogger $ + Log.msg (Log.val "Running job") + . Log.field "queue_name" conversationsQueueName + . Log.field "payload_type" (conversationsJobPayloadTypeName job.payload) + case job.payload of + AdminlessDeletion payload -> runAppT env $ runAdminlessDeletionJob extEnv (mapJobPayload (const payload) job) + AdminlessReminder payload -> runAppT env $ runAdminlessReminderJob extEnv (mapJobPayload (const payload) job) + + cronJob <- case ArbiterWorkerCron.cronJob + "meetings-cleanup" + (serializeCronSchedule runnerConfig.jobRunnerSchedule) + ArbiterWorkerCron.SkipOverlap + ( \_ scheduledFor -> + (ArbiterCore.defaultGroupedJob "meetings-cleanup" (MeetingsCleanup MeetingsCleanupJob)) + { ArbiterCore.notVisibleUntil = Just scheduledFor, + ArbiterCore.maxAttempts = Just 3 + } + ) of + Left err -> throwIO . userError $ "Invalid cron schedule for meetings-cleanup: " <> err + Right job -> pure job + + meetingsWorkerConfig <- + ( ArbiterWorker.defaultWorkerConfig + arbiterConnStr + runnerConfig.jobRunnerSettings.jobWorkerThreads + meetingsWorkerHandler :: + IO + ( ArbiterWorker.WorkerConfig + (WireArbiter registry) + MeetingsJobPayload + () + ) + ) + + conversationsWorkerConfig <- + ( ArbiterWorker.defaultWorkerConfig + arbiterConnStr + runnerConfig.jobRunnerSettings.jobWorkerThreads + conversationsWorkerHandler :: + IO + ( ArbiterWorker.WorkerConfig + (WireArbiter registry) + ConversationsJobPayload + () + ) + ) + + let meetingsWorkerConfig' = + applyExplicitDefaults + runnerConfig.jobRunnerSettings + meetingsWorkerConfig + { ArbiterWorkerConfig.cronJobs = [cronJob] + } + conversationsWorkerConfig' = + applyExplicitDefaults + runnerConfig.jobRunnerSettings + conversationsWorkerConfig + workerPools = + [ ArbiterWorker.namedWorkerPool meetingsWorkerConfig', + ArbiterWorker.namedWorkerPool conversationsWorkerConfig' + ] + shutdownWorkerPools _ = do + ArbiterWorker.shutdownWorker meetingsWorkerConfig' + ArbiterWorker.shutdownWorker conversationsWorkerConfig' + + workerAsync <- + Async.async $ + runWireArbiter arbiterEnv $ + ArbiterWorker.runWorkerPools + (Proxy @registry) + workerPools + shutdownWorkerPools + + pure $ do + ArbiterWorker.shutdownWorker meetingsWorkerConfig' + ArbiterWorker.shutdownWorker conversationsWorkerConfig' + Async.cancel workerAsync + +meetingsJobPayloadTypeName :: MeetingsJobPayload -> Text +meetingsJobPayloadTypeName = \case + MeetingsCleanup _ -> "meetings_cleanup" + +conversationsJobPayloadTypeName :: ConversationsJobPayload -> Text +conversationsJobPayloadTypeName = \case + AdminlessDeletion _ -> "adminless_deletion" + AdminlessReminder _ -> "adminless_reminder" + +mapJobPayload :: (a -> b) -> JobRead a -> JobRead b +mapJobPayload f job = + ArbiterCore.Job + { ArbiterCore.primaryKey = job.primaryKey, + ArbiterCore.payload = f job.payload, + ArbiterCore.queueName = job.queueName, + ArbiterCore.groupKey = job.groupKey, + ArbiterCore.insertedAt = job.insertedAt, + ArbiterCore.updatedAt = job.updatedAt, + ArbiterCore.attempts = job.attempts, + ArbiterCore.lastError = job.lastError, + ArbiterCore.priority = job.priority, + ArbiterCore.lastAttemptedAt = job.lastAttemptedAt, + ArbiterCore.notVisibleUntil = job.notVisibleUntil, + ArbiterCore.dedupKey = job.dedupKey, + ArbiterCore.maxAttempts = job.maxAttempts, + ArbiterCore.parentId = job.parentId, + ArbiterCore.parentState = job.parentState, + ArbiterCore.suspended = job.suspended, + ArbiterCore.claimedBy = job.claimedBy, + ArbiterCore.admission = job.admission + } + +applyExplicitDefaults :: + JobWorkerSettings -> + ArbiterWorker.WorkerConfig m payload result -> + ArbiterWorker.WorkerConfig m payload result +applyExplicitDefaults settings cfg = + cfg + { -- How often the dispatcher wakes up to look for newly visible jobs. + -- Lower values reduce discovery latency at the cost of more DB traffic. + ArbiterWorkerConfig.pollInterval = settings.jobPollInterval, + -- How long a claimed job stays invisible while a worker processes it. + -- Must exceed the job heartbeat interval so active jobs are not reclaimed. + ArbiterWorkerConfig.visibilityTimeout = settings.jobVisibilityTimeout, + -- How often a running job refreshes its visibility timeout. + -- Keeps long-running jobs from being reclaimed mid-flight. + ArbiterWorkerConfig.jobHeartbeatInterval = settings.jobHeartbeatInterval, + -- How often the worker process updates its own heartbeat and pause state. + -- This drives liveness, re-registration, and paused-state reconciliation. + ArbiterWorkerConfig.workerHeartbeatInterval = settings.jobWorkerHeartbeatInterval, + -- Retry strategy for transient worker failures. + -- Arbiter uses exponential backoff with jitter by default. + ArbiterWorkerConfig.backoffStrategy = + ArbiterWorker.exponentialBackoff + settings.jobBackoffBase + settings.jobBackoffCap, + -- Jitter mode for retry delays. + -- Equal jitter smooths retry spikes without making them too aggressive. + ArbiterWorkerConfig.jitter = settings.jobJitter, + -- How long the worker waits for in-flight jobs during shutdown. + -- If set, the pool exits after this grace period instead of waiting forever. + ArbiterWorkerConfig.gracefulShutdownTimeout = settings.jobGracefulShutdownTimeout, + -- How often the reaper runs. It refreshes groups, sweeps stale workers, + -- and moves exhausted jobs to the DLQ. + ArbiterWorkerConfig.reaperInterval = settings.jobReaperInterval, + -- Maximum time allowed for one reaper pass. + ArbiterWorkerConfig.reaperTimeout = settings.jobReaperTimeout, + -- How old a worker heartbeat may be before it is considered stale. + -- Stale workers are swept from the registry by the reaper. + ArbiterWorkerConfig.workerStaleThreshold = settings.jobWorkerStaleThreshold + } diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index 57330f9f1e3..088ba777774 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -20,6 +20,7 @@ module Wire.Effects ) where +import Arbiter.Core qualified as ArbiterCore import Bilge qualified import Bilge.Retry import Cassandra (ClientState) @@ -90,9 +91,12 @@ import Wire.GalleyAPIAccess.Rpc (interpretGalleyAPIAccessToRpc) import Wire.GundeckAPIAccess import Wire.HashPassword (HashPassword) import Wire.HashPassword.Interpreter (runHashPassword) +import Wire.JobSubsystem (JobSubsystem, JobSubsystemConfig (..)) +import Wire.JobSubsystem.Interpreter (interpretJobSubsystem) import Wire.LegalHoldStore (LegalHoldStore) import Wire.LegalHoldStore.Cassandra (interpretLegalHoldStoreToCassandra) import Wire.LegalHoldStore.Env (LegalHoldEnv (..)) +import Wire.MigrationLock (MigrationLockError) import Wire.NotificationSubsystem (NotificationSubsystem) import Wire.NotificationSubsystem.Interpreter import Wire.Options.Galley (GuestLinkTTLSeconds) @@ -124,6 +128,8 @@ import Wire.TeamCollaboratorsSubsystem.Interpreter (interpretTeamCollaboratorsSu import Wire.TeamFeatureStore (TeamFeatureStore) import Wire.TeamFeatureStore.Cassandra (interpretTeamFeatureStoreToCassandra) import Wire.TeamFeatureStore.Error (TeamFeatureStoreError) +import Wire.TeamFeatureStore.Migrating (interpretTeamFeatureStoreToCassandraAndPostgres) +import Wire.TeamFeatureStore.Postgres (interpretTeamFeatureStoreToPostgres) import Wire.TeamJournal (TeamJournal) import Wire.TeamJournal.Aws (interpretTeamJournal) import Wire.TeamStore (TeamStore) @@ -210,6 +216,8 @@ type BackgroundWorkerEffects = Now, TeamJournal, LegalHoldStore, + JobSubsystem, + Input RequestId, TeamCollaboratorsStore, TeamStore, ConversationStore, @@ -242,6 +250,7 @@ type BackgroundWorkerEffects = Error (Tagged CodeStoreNotFound ()), Error TeamFeatureStoreError, Error TeamCollaboratorsError, + Error MigrationLockError, Error UnreachableBackends, Error InternalError, Error MigrationError, @@ -290,6 +299,7 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . mapError @MigrationError (T.pack . show) . mapError @InternalError (TL.toStrict . internalErrorDescription) . mapError @UnreachableBackends (T.pack . show) + . mapError @MigrationLockError (const ("Migration lock error" :: Text)) . mapError @TeamCollaboratorsError (const ("Team collaborators error" :: Text)) . mapError @TeamFeatureStoreError (const ("Team feature store error" :: Text)) . mapError @(Tagged 'CodeStoreNotFound ()) (const ("Code store not found" :: Text)) @@ -317,11 +327,13 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . interpretProposalStoreToCassandra . interpretServiceStoreToCassandra env.cassandraBrig . interpretUserGroupStoreToPostgres - . interpretTeamFeatureStoreToCassandra + . interpretTeamFeatureStore . interpretUserClientIndexStoreToCassandra env.cassandraGalley . interpretConversationStoreByMigration env.postgresMigration.conversation env.cassandraGalley . interpretTeamStoreToCassandra . interpretTeamCollaboratorsStoreToPostgres + . runInputConst @RequestId requestId + . interpretJobSubsystem jobSubsystemConfig . interpretLegalHoldStoreToCassandra (env.conversationSubsystemConfig.legalholdDefaults) . interpretTeamJournal Nothing . nowToIO @@ -355,11 +367,15 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . interpretTeamCollaboratorsSubsystem . interpretConversationSubsystem where - convCodesStoreInterpreter = - case env.postgresMigration.conversationCodes of - CassandraStorage -> interpretCodeStoreToCassandra - MigrationToPostgresql -> interpretCodeStoreToCassandraAndPostgres - PostgresqlStorage -> interpretCodeStoreToPostgres + interpretTeamFeatureStore = case env.postgresMigration.teamFeatures of + CassandraStorage -> interpretTeamFeatureStoreToCassandra + MigrationToPostgresql -> interpretTeamFeatureStoreToCassandraAndPostgres + PostgresqlStorage -> interpretTeamFeatureStoreToPostgres + + convCodesStoreInterpreter = case env.postgresMigration.conversationCodes of + CassandraStorage -> interpretCodeStoreToCassandra + MigrationToPostgresql -> interpretCodeStoreToCassandraAndPostgres + PostgresqlStorage -> interpretCodeStoreToPostgres legalHoldEnv = let makeReq fpr url rb = makeVerifiedRequestIO env.logger extEnv fpr url rb makeReqFresh fpr url rb = makeVerifiedRequestFreshManagerIO env.logger fpr url rb @@ -372,6 +388,10 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = http2Manager = env.http2Manager, requestId = requestId } + jobSubsystemConfig = + JobSubsystemConfig + { jobSubsystemSchemaName = ArbiterCore.defaultSchemaName + } backendQueueEnv = BackendNotificationQueueAccess.Env { channelMVar = env.amqpBackendNotificationsChannel, diff --git a/services/background-worker/src/Wire/MeetingsCleanupWorker.hs b/services/background-worker/src/Wire/MeetingsCleanupWorker.hs index 1fd4db93ff9..5b00955373f 100644 --- a/services/background-worker/src/Wire/MeetingsCleanupWorker.hs +++ b/services/background-worker/src/Wire/MeetingsCleanupWorker.hs @@ -16,8 +16,8 @@ -- with this program. If not, see . module Wire.MeetingsCleanupWorker - ( startWorker, - CleanupConfig (..), + ( CleanupConfig (..), + runCleanupOldMeetings, ) where @@ -28,11 +28,8 @@ import Data.Time.Clock import Imports import Polysemy.Error (runError) import Prometheus (incCounter) -import System.Cron (Job (..), forkJob) import System.Logger qualified as Log -import Wire.BackgroundWorker.Env (AppT, Env (..), MeetingsCleanupMetrics (..), runAppT) -import Wire.BackgroundWorker.Options (MeetingsCleanupConfig (..)) -import Wire.BackgroundWorker.Util (CleanupAction) +import Wire.BackgroundWorker.Env (AppT, Env (..), MeetingsCleanupMetrics (..)) import Wire.Effects import Wire.ExternalAccess.External import Wire.MeetingsStore.Postgres (interpretMeetingsStoreToPostgres) @@ -45,37 +42,6 @@ data CleanupConfig = CleanupConfig } deriving (Show, Eq) --- | Start the meetings cleanup worker thread --- --- This worker runs periodically to clean up old meetings based on the configuration. -startWorker :: - MeetingsCleanupConfig -> - AppT IO CleanupAction -startWorker config = do - env <- ask - Log.info env.logger $ - Log.msg (Log.val "Starting meetings cleanup worker") - . Log.field "schedule" (show config.schedule) - . Log.field "clean_older_than_hours" config.cleanOlderThanHours - - void . liftIO $ do - forkJob $ - Job config.schedule $ - runAppT env $ do - Log.info env.logger $ Log.msg (Log.val "Starting scheduled meetings cleanup") - runCleanupOldMeetings (configFromOptions config) - liftIO $ incCounter env.meetingsCleanupMetrics.runsCounter - - pure $ pure () - --- | Convert MeetingsCleanupConfig to CleanupConfig -configFromOptions :: MeetingsCleanupConfig -> CleanupConfig -configFromOptions cfg = - CleanupConfig - { retentionHours = cfg.cleanOlderThanHours, - batchSize = cfg.batchSize - } - -- | Main cleanup function that orchestrates the cleanup process runCleanupOldMeetings :: CleanupConfig -> AppT IO () runCleanupOldMeetings config = do @@ -95,6 +61,7 @@ runCleanupOldMeetings config = do Log.info env.logger $ Log.msg (Log.val "Completed cleanup of old meetings") . Log.field "total_deleted" totalDeleted + liftIO $ incCounter env.meetingsCleanupMetrics.runsCounter cleanupLoop :: Env -> UTCTime -> NominalDiffTime -> Int -> Int64 -> AppT IO Int64 cleanupLoop env cutoffTime validityPeriod batchSize totalSoFar = do diff --git a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs index 4b32861ba3e..7222120d93a 100644 --- a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs +++ b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs @@ -32,6 +32,7 @@ import Data.Domain import Data.Id import Data.Misc import Data.Range +import Data.Secret (secretText) import Data.Sequence qualified as Seq import Data.Text qualified as Text import Data.Text.Encoding qualified as Text @@ -384,6 +385,7 @@ spec = do guestLinkTTLSeconds = Nothing passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing + arbiterConnStr = secretText "" convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = @@ -447,6 +449,7 @@ spec = do guestLinkTTLSeconds = Nothing passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing + arbiterConnStr = secretText "" convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = diff --git a/services/background-worker/test/Test/Wire/Util.hs b/services/background-worker/test/Test/Wire/Util.hs index ddecd07b9e9..5d89532bfec 100644 --- a/services/background-worker/test/Test/Wire/Util.hs +++ b/services/background-worker/test/Test/Wire/Util.hs @@ -24,6 +24,7 @@ import Data.Domain (Domain (Domain)) import Data.Misc import Data.Proxy import Data.Range +import Data.Secret (secretText) import Imports import Network.HTTP.Client hiding (Proxy) import System.Logger.Class qualified as Logger @@ -83,6 +84,7 @@ testEnv = do guestLinkTTLSeconds = Nothing passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing + arbiterConnStr = secretText "" convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index 9a4a59dda72..dd1192ccf3f 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -137,7 +137,7 @@ import Data.Text.IO qualified as Text import Data.Time.Clock import Database.Bloodhound qualified as ES import HTTP2.Client.Manager (Http2Manager, http2ManagerWithSSLCtx) -import Hasql.Pool.Extended +import Hasql.Pool.Extended (initPostgresPool) import Hasql.Pool.Extended qualified as HasqlPool import Imports import Network.AMQP qualified as Q diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index 358aa19ea7a..b316bfd3d04 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -69,8 +69,8 @@ import Wire.AuthenticationSubsystem.Interpreter import Wire.BackendNotificationQueueAccess (BackendNotificationQueueAccess) import Wire.BackendNotificationQueueAccess.RabbitMq (interpretBackendNotificationQueueAccess) import Wire.BackendNotificationQueueAccess.RabbitMq qualified as BackendNotificationQueueAccess -import Wire.BackgroundJobsPublisher (BackgroundJobsPublisher) -import Wire.BackgroundJobsPublisher.RabbitMQ (interpretBackgroundJobsPublisherRabbitMQ) +import Wire.BackgroundJobsPublisher (BackgroundJobPublisher) +import Wire.BackgroundJobsPublisher.RabbitMQ (interpretBackgroundJobPublisherRabbitMQ) import Wire.BlockListStore import Wire.BlockListStore.Cassandra import Wire.ClientStore (ClientStore) @@ -205,7 +205,7 @@ type BrigLowerLevelEffects = Wire.Events.Events, NotificationSubsystem, BackendNotificationQueueAccess, - BackgroundJobsPublisher, + BackgroundJobPublisher, RateLimit, UserKeyStore, UserStore, @@ -489,7 +489,7 @@ runBrigToIO e (AppT ma) = do . userStoreInterpreter . interpretUserKeyStoreCassandra e.casClient . interpretRateLimit e.rateLimitEnv - . interpretBackgroundJobsPublisherRabbitMQ e.requestId e.amqpJobsPublisherChannel + . interpretBackgroundJobPublisherRabbitMQ e.requestId e.amqpJobsPublisherChannel . interpretBackendNotificationQueueAccess (Just backendNotificationQueueEnv) . runNotificationSubsystemGundeck (defaultNotificationSubsystemConfig e.requestId) . runEvents diff --git a/services/galley/default.nix b/services/galley/default.nix index 3f9eb5cb466..949e941b3a9 100644 --- a/services/galley/default.nix +++ b/services/galley/default.nix @@ -7,6 +7,7 @@ , aeson-qq , amazonka , amqp +, arbiter-core , async , base , base64-bytestring @@ -121,6 +122,7 @@ mkDerivation { aeson amazonka amqp + arbiter-core async base bilge diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index 5ca4fdc34c8..f7c48e3bc00 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -197,6 +197,7 @@ library , aeson >=2.0.1.0 , amazonka >=1.4.5 , amqp + , arbiter-core , async >=2.0 , base >=4.6 && <5 , bilge >=0.21.1 diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index 78183816268..010ea8cf59c 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -42,6 +42,7 @@ module Galley.App ) where +import Arbiter.Core qualified as ArbiterCore import Bilge hiding (Request, header, host, options, port, statusCode, statusMessage) import Cassandra hiding (Set) import Cassandra.Util (initCassandraForService) @@ -124,6 +125,8 @@ import Wire.FireAndForget import Wire.GundeckAPIAccess (GundeckAPIAccess, runGundeckAPIAccess) import Wire.HashPassword import Wire.HashPassword.Interpreter +import Wire.JobSubsystem (JobSubsystem, JobSubsystemConfig (..)) +import Wire.JobSubsystem.Interpreter (interpretJobSubsystem) import Wire.LegalHoldStore (LegalHoldStore) import Wire.LegalHoldStore.Cassandra (interpretLegalHoldStoreToCassandra) import Wire.LegalHoldStore.Env (LegalHoldEnv (..)) @@ -191,6 +194,8 @@ import Wire.UserGroupStore.Postgres (interpretUserGroupStoreToPostgres) type GalleyEffects = '[ MeetingsSubsystem, ConversationSubsystem, + JobSubsystem, + Input RequestId, FederationSubsystem, TeamCollaboratorsSubsystem, Input AllTeamFeatures, @@ -549,6 +554,11 @@ evalGalley e = . runInputSem getAllTeamFeaturesForServer . interpretTeamCollaboratorsSubsystem . runFederationSubsystem conversationSubsystemConfig.federationProtocols + . runInputConst (e ^. reqId) + . interpretJobSubsystem + JobSubsystemConfig + { jobSubsystemSchemaName = ArbiterCore.defaultSchemaName + } . interpretConversationSubsystem . Meeting.interpretMeetingsSubsystem meetingValidityPeriod where diff --git a/services/galley/src/Galley/Run.hs b/services/galley/src/Galley/Run.hs index 9ea6b3870ae..c01f9490290 100644 --- a/services/galley/src/Galley/Run.hs +++ b/services/galley/src/Galley/Run.hs @@ -23,6 +23,7 @@ where import AWS.Util (readAuthExpiration) import Amazonka qualified as AWS +import Arbiter.Core qualified as ArbiterCore import Cassandra (runClient, shutdown) import Cassandra.Schema (versionCheck) import Control.Concurrent.Async qualified as Async @@ -45,7 +46,7 @@ import Galley.Cassandra import Galley.Env import Galley.Monad import Galley.Queue qualified as Q -import Hasql.Pool.Extended (rawPool) +import Hasql.Pool.Extended (postgresqlConnectionStringWithPassword, rawPool) import Imports import Network.HTTP.Media.RenderHeader qualified as HTTPMedia import Network.HTTP.Types qualified as HTTP @@ -67,6 +68,7 @@ import Wire.API.Routes.Public.Galley import Wire.API.Routes.Version import Wire.API.Routes.Version.Wai import Wire.AWS (awsEnv) +import Wire.JobSubsystem.Migrations (runJobMigrations) import Wire.OpenTelemetry (withTracerC) import Wire.Options.Galley import Wire.PostgresMigrations (runAllMigrations) @@ -76,6 +78,12 @@ run opts = lowerCodensity do tracer <- withTracerC (app, env) <- mkApp opts lift $ runAllMigrations env._hasqlPool.rawPool env._applog + arbiterConnStr <- + lift $ + postgresqlConnectionStringWithPassword + (opts ^. postgresql) + (opts ^. postgresqlPassword) + lift $ runJobMigrations arbiterConnStr ArbiterCore.defaultSchemaName let settings' = newSettings $ defaultServer From d5ec6d5f0867549dbe1a9daa354fed4ecbe130ec Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 21 Jul 2026 12:27:29 +0200 Subject: [PATCH 020/113] WPB-27060 Add optional field `supportEmail` to `deeplink.json` (#5351) --- changelog.d/6-federation/WPB-27060 | 1 + charts/nginz/templates/configmap.yaml | 6 ++++++ charts/nginz/values.yaml | 2 ++ docs/src/understand/associate/deeplink.md | 6 ++++++ 4 files changed, 15 insertions(+) create mode 100644 changelog.d/6-federation/WPB-27060 diff --git a/changelog.d/6-federation/WPB-27060 b/changelog.d/6-federation/WPB-27060 new file mode 100644 index 00000000000..d266b42b8c2 --- /dev/null +++ b/changelog.d/6-federation/WPB-27060 @@ -0,0 +1 @@ +`deeplink.json` now contains a new optional field `supportEmail` that may be used by clients. diff --git a/charts/nginz/templates/configmap.yaml b/charts/nginz/templates/configmap.yaml index 69f1887056e..6b159880996 100644 --- a/charts/nginz/templates/configmap.yaml +++ b/charts/nginz/templates/configmap.yaml @@ -42,6 +42,9 @@ data: ) "title" .Values.nginx_conf.deeplink.title }} + {{- with .Values.nginx_conf.deeplink.supportEmail }} + {{- $_ := set $deeplink "supportEmail" . }} + {{- end }} {{- if hasKey .Values.nginx_conf.deeplink "apiProxy" }} {{- $_ := set $deeplink "apiProxy" (dict "host" .Values.nginx_conf.deeplink.apiProxy.host @@ -77,6 +80,9 @@ data: ) "title" $config.title }} + {{- with $config.supportEmail }} + {{- $_ := set $deeplink "supportEmail" . }} + {{- end }} {{- if hasKey $config "apiProxy" }} {{- $_ := set $deeplink "apiProxy" (dict "host" $config.apiProxy.host diff --git a/charts/nginz/values.yaml b/charts/nginz/values.yaml index ae9d3c84702..0f081d64870 100644 --- a/charts/nginz/values.yaml +++ b/charts/nginz/values.yaml @@ -62,6 +62,7 @@ nginx_conf: # accountsURL: "https://account.example.com" # blackListURL: "https://clientblacklist.example.com/prod" # websiteURL: "https://example.com" + # supportEmail: "support@example.com" # (optional) # apiProxy: # (optional) # host: "socks5.proxy.com" # port: 1080 @@ -78,6 +79,7 @@ nginx_conf: # teamsURL: "https://teams.red.example.com" # accountsURL: "https://account.red.example.com" # websiteURL: "https://red.example.com" + # supportEmail: "support@red.example.com" # (optional) # title: "Production red.example.com" # apiProxy: # (optional) # host: "socks5.proxy.com" diff --git a/docs/src/understand/associate/deeplink.md b/docs/src/understand/associate/deeplink.md index 9eb00b304db..0a8a65b0a1b 100644 --- a/docs/src/understand/associate/deeplink.md +++ b/docs/src/understand/associate/deeplink.md @@ -80,6 +80,10 @@ Note on the meaning of the URLs used below: : Arbitrary string that may show up in a few places in the app. Should be used as an identifier of the backend servers in question. +`supportEmail` (optional) + +: An email address used by clients for support-related contact actions. If it is not configured, the `supportEmail` key is omitted from `deeplink.json`. + ### With Added Proxy `apiProxy:host (optional)` @@ -112,6 +116,7 @@ nginz: accountsURL: "https://account.example.com" blackListURL: "https://clientblacklist.example.com/prod" websiteURL: "https://example.com" + supportEmail: "support@example.com" # (optional) apiProxy: # (optional) host: "socks5.proxy.com" port: 1080 @@ -160,6 +165,7 @@ nginz: teamsURL: "https://teams.red.example.com" accountsURL: "https://account.red.example.com" websiteURL: "https://red.example.com" + supportEmail: "support@red.example.com" # (optional) title: "Production red.example.com" apiProxy: # (optional) host: "socks5.proxy.com" From 6d272aa9e151dad1c053c4352c97476fed471174 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Tue, 21 Jul 2026 18:49:10 +0200 Subject: [PATCH 021/113] WPB-26705: move meeting qualified id to event envelope (#5349) --- .../2-features/wpb-26705-meeting-events.md | 9 +- integration/test/Test/Meetings.hs | 24 +++- .../src/Wire/API/Event/Conversation.hs | 26 +--- libs/wire-api/src/Wire/API/Event/Meeting.hs | 120 ++++++++++++++++++ .../golden/Test/Wire/API/Golden/Manual.hs | 7 + .../Wire/API/Golden/Manual/MeetingEvent.hs | 59 +++++++++ ...tObject_Event_meeting_create_manual_1.json | 19 +++ ...tObject_Event_meeting_delete_manual_1.json | 19 +++ ...tObject_Event_meeting_update_manual_1.json | 19 +++ .../test/unit/Test/Wire/API/Conversation.hs | 3 - libs/wire-api/wire-api.cabal | 2 + .../src/Wire/MeetingsSubsystem/Interpreter.hs | 25 ++-- .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 34 ++--- 13 files changed, 295 insertions(+), 71 deletions(-) create mode 100644 libs/wire-api/src/Wire/API/Event/Meeting.hs create mode 100644 libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MeetingEvent.hs create mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json create mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json create mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json diff --git a/changelog.d/2-features/wpb-26705-meeting-events.md b/changelog.d/2-features/wpb-26705-meeting-events.md index 6eff9dce988..ab7928a5020 100644 --- a/changelog.d/2-features/wpb-26705-meeting-events.md +++ b/changelog.d/2-features/wpb-26705-meeting-events.md @@ -1,7 +1,8 @@ * Added meeting lifecycle events: `meeting.create`, `meeting.update`, and `meeting.delete` (WPB-26705). These websocket notifications are pushed to all local members of the meeting's conversation on every successful create, update, - and delete operation. The events use the standard conversation event envelope: - each payload contains the event `type`, the meeting's qualified ID in the `data` - field, the `qualified_conversation`, `qualified_from`, `via`, `time`, and - optional `team`. + and delete operation. Each payload carries the event `type`, the meeting's + qualified ID in the top-level `qualified_id` field, the + `qualified_conversation`, `qualified_from`, `via`, `time`, and optional `team`. + Meeting events use a dedicated event envelope (not the conversation event + envelope). diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs index 5270e7730b3..a1abf5525b0 100644 --- a/integration/test/Test/Meetings.hs +++ b/integration/test/Test/Meetings.hs @@ -31,8 +31,10 @@ testMeetingCreate = do resp <- postMeetings owner newMeeting assertSuccess resp void $ awaitMatch isConvCreateMeetingNotif ws - void $ awaitMatch isMeetingCreateNotif ws - getJSON 201 resp + m <- getJSON 201 resp + createNotif <- awaitMatch isMeetingCreateNotif ws + assertMeetingNotif createNotif (m %. "qualified_id") + pure m meeting %. "title" `shouldMatch` ("Team Standup" :: String) meeting %. "qualified_creator" %. "id" `shouldMatch` ownerId @@ -96,6 +98,18 @@ assertConversationMatchesLegacy meeting = do legacyConvId <- meeting %. "qualified_conversation" convId `shouldMatch` legacyConvId +-- | Assert a meeting lifecycle notification carries the meeting's qualified id flat +-- at payload.0.qualified_id and has NO "data" wrapper (proves the no-nesting +-- contract). +assertMeetingNotif :: + (HasCallStack, MakesValue notif, MakesValue qid) => + notif -> + qid -> + App () +assertMeetingNotif notif qid = do + notif %. "payload.0.qualified_id" `shouldMatch` qid + assertFieldMissing notif "payload.0.data" + -- | Helper to create a default new meeting JSON object defaultMeetingJson :: String -> UTCTime -> UTCTime -> [String] -> Value defaultMeetingJson title startTime endTime invitedEmails = @@ -217,7 +231,8 @@ testMeetingRecurrence = do r2 <- withWebSocket owner $ \ws -> do resp <- putMeeting owner domain meetingId updatedMeeting assertSuccess resp - void $ awaitMatch isMeetingUpdateNotif ws + updateNotif <- awaitMatch isMeetingUpdateNotif ws + assertMeetingNotif updateNotif (object ["id" .= meetingId, "domain" .= domain]) pure resp updated <- getJSON 200 r2 @@ -460,7 +475,8 @@ testMeetingDelete = do (meetingId, domain) <- getMeetingIdAndDomain meeting withWebSocket owner $ \ws -> do deleteMeeting owner domain meetingId >>= assertStatus 200 - void $ awaitMatch isMeetingDeleteNotif ws + deleteNotif <- awaitMatch isMeetingDeleteNotif ws + assertMeetingNotif deleteNotif (object ["id" .= meetingId, "domain" .= domain]) getMeeting owner domain meetingId >>= assertStatus 404 testMeetingDeleteNotFound :: (HasCallStack) => App () diff --git a/libs/wire-api/src/Wire/API/Event/Conversation.hs b/libs/wire-api/src/Wire/API/Event/Conversation.hs index ea2869f273a..f954da761f2 100644 --- a/libs/wire-api/src/Wire/API/Event/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Event/Conversation.hs @@ -28,6 +28,8 @@ module Wire.API.Event.Conversation EventVia (..), EventFrom (..), eventFromUserId, + mkEventFrom, + eventVia, AddCodeResult (..), createConversationEventData, isCellsConversationEvent, @@ -58,9 +60,6 @@ module Wire.API.Event.Conversation _EdMLSMessage, _EdMLSWelcome, _EdAddPermissionUpdate, - _EdMeetingCreate, - _EdMeetingUpdate, - _EdMeetingDelete, -- * Event data helpers SimpleMember (..), @@ -198,9 +197,6 @@ data EventType | ProtocolUpdate | AddPermissionUpdate | ConvHistoryUpdate - | MeetingCreate - | MeetingUpdate - | MeetingDelete | ConvAdminlessReminder deriving stock (Eq, Show, Generic, Enum, Bounded, Ord) deriving (Arbitrary) via (GenericUniform EventType) @@ -231,9 +227,6 @@ instance ToSchema EventType where element "conversation.protocol-update" ProtocolUpdate, element "conversation.add-permission-update" AddPermissionUpdate, element "conversation.history-update" ConvHistoryUpdate, - element "meeting.create" MeetingCreate, - element "meeting.update" MeetingUpdate, - element "meeting.delete" MeetingDelete, element "conversation.adminless-reminder" ConvAdminlessReminder ] @@ -259,9 +252,6 @@ data EventData | EdProtocolUpdate P.ProtocolTag | EdAddPermissionUpdate Conv.AddPermissionUpdate | EdConvHistoryUpdate History - | EdMeetingCreate (Qualified MeetingId) - | EdMeetingUpdate (Qualified MeetingId) - | EdMeetingDelete (Qualified MeetingId) | EdAdminlessReminder AdminlessReminder deriving stock (Eq, Show, Generic) @@ -288,9 +278,6 @@ genEventData = \case ProtocolUpdate -> EdProtocolUpdate <$> arbitrary AddPermissionUpdate -> EdAddPermissionUpdate <$> arbitrary ConvHistoryUpdate -> EdConvHistoryUpdate <$> arbitrary - MeetingCreate -> EdMeetingCreate <$> arbitrary - MeetingUpdate -> EdMeetingUpdate <$> arbitrary - MeetingDelete -> EdMeetingDelete <$> arbitrary ConvAdminlessReminder -> EdAdminlessReminder <$> arbitrary eventDataType :: EventData -> EventType @@ -315,9 +302,6 @@ eventDataType (EdConvReset _) = ConvReset eventDataType (EdProtocolUpdate _) = ProtocolUpdate eventDataType (EdAddPermissionUpdate _) = AddPermissionUpdate eventDataType (EdConvHistoryUpdate _) = ConvHistoryUpdate -eventDataType (EdMeetingCreate _) = MeetingCreate -eventDataType (EdMeetingUpdate _) = MeetingUpdate -eventDataType (EdMeetingDelete _) = MeetingDelete eventDataType (EdAdminlessReminder _) = ConvAdminlessReminder createConversationEventData :: @@ -350,9 +334,6 @@ isCellsConversationEvent eventType = ProtocolUpdate -> False AddPermissionUpdate -> False ConvHistoryUpdate -> False - MeetingCreate -> False - MeetingUpdate -> False - MeetingDelete -> False ConvAdminlessReminder -> False -------------------------------------------------------------------------------- @@ -570,9 +551,6 @@ taggedEventDataSchema = ProtocolUpdate -> tag _EdProtocolUpdate (unnamed (unProtocolUpdate <$> P.ProtocolUpdate .= schema)) AddPermissionUpdate -> tag _EdAddPermissionUpdate (unnamed schema) ConvHistoryUpdate -> tag _EdConvHistoryUpdate (unnamed schema) - MeetingCreate -> tag _EdMeetingCreate (unnamed schema) - MeetingUpdate -> tag _EdMeetingUpdate (unnamed schema) - MeetingDelete -> tag _EdMeetingDelete (unnamed schema) ConvAdminlessReminder -> tag _EdAdminlessReminder (unnamed schema) memberLeaveSchema :: ValueSchema NamedSwaggerDoc (EdMemberLeftReason, QualifiedUserIdList) diff --git a/libs/wire-api/src/Wire/API/Event/Meeting.hs b/libs/wire-api/src/Wire/API/Event/Meeting.hs new file mode 100644 index 00000000000..f6eff5a437c --- /dev/null +++ b/libs/wire-api/src/Wire/API/Event/Meeting.hs @@ -0,0 +1,120 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . +{-# LANGUAGE StrictData #-} + +module Wire.API.Event.Meeting + ( -- * Event + Event (..), + EventType (..), + + -- * Envelope + EventFrom (..), + ) +where + +import Control.Applicative (optional) +import Data.Aeson (FromJSON (..), ToJSON (..)) +import Data.Aeson.KeyMap qualified as KeyMap +import Data.Id +import Data.Json.Util +import Data.OpenApi qualified as S +import Data.Qualified +import Data.Schema +import Data.Time (UTCTime) +import Imports +import Wire.API.Event.Conversation (EventFrom (..), eventFromUserId, eventVia, mkEventFrom) +import Wire.Arbitrary (Arbitrary (arbitrary), GenericUniform (..)) + +-------------------------------------------------------------------------------- +-- EventType + +data EventType = Create | Update | Delete + deriving stock (Eq, Show, Generic, Enum, Bounded, Ord) + deriving (Arbitrary) via (GenericUniform EventType) + deriving (FromJSON, ToJSON, S.ToSchema) via Schema EventType + +instance ToSchema EventType where + schema = + enum @Text $ + mconcat + [ element "meeting.create" Create, + element "meeting.update" Update, + element "meeting.delete" Delete + ] + +-------------------------------------------------------------------------------- +-- Event + +-- | A self-contained meeting lifecycle event. Unlike conversation events, the +-- meeting's qualified id is carried flat at the envelope top level +-- (@qualified_id@), with no @data@ wrapper. The 'EventFrom' envelope helper is +-- shared with "Wire.API.Event.Conversation". +data Event = Event + { evtType :: EventType, + evtMeeting :: Qualified MeetingId, + evtConv :: Qualified ConvId, + evtFrom :: EventFrom, + evtTime :: UTCTime, + evtTeam :: Maybe TeamId + } + deriving stock (Eq, Show, Generic) + +instance Arbitrary Event where + arbitrary = + Event + <$> arbitrary + <*> arbitrary + <*> arbitrary + <*> arbitrary + <*> (milli <$> arbitrary) + <*> arbitrary + where + milli = fromUTCTimeMillis . toUTCTimeMillis + +instance ToSchema Event where + schema = object eventObjectSchema + +eventObjectSchema :: ObjectSchema SwaggerDoc Event +eventObjectSchema = + mk + <$> evtType .= field "type" schema + <*> evtMeeting .= field "qualified_id" schema + <* (qUnqualified . evtConv) .= optional (field "conversation" schema) + <*> evtConv .= field "qualified_conversation" schema + <* (qUnqualified . eventFromUserId . evtFrom) .= optional (field "from" schema) + <*> (eventFromUserId . evtFrom) .= field "qualified_from" schema + <*> (eventVia . evtFrom) .= field "via" schema + <*> (toUTCTimeMillis . evtTime) .= field "time" (fromUTCTimeMillis <$> schema) + <*> evtTeam .= maybe_ (optField "team" schema) + where + mk typ meeting cid uid evVia tm tid = + Event typ meeting cid (mkEventFrom evVia uid) tm tid + +instance ToJSONObject Event where + toJSONObject = + KeyMap.fromList + . fromMaybe [] + . schemaOut eventObjectSchema + +instance FromJSON Event where + parseJSON = schemaParseJSON + +instance ToJSON Event where + toJSON = schemaToJSON + +instance S.ToSchema Event where + declareNamedSchema = schemaToSwagger diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs index 385d61ce463..c726b3f2e25 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs @@ -50,6 +50,7 @@ import Test.Wire.API.Golden.Manual.ListUsersById import Test.Wire.API.Golden.Manual.LoginId_user import Test.Wire.API.Golden.Manual.Login_user import Test.Wire.API.Golden.Manual.MLSKeys +import Test.Wire.API.Golden.Manual.MeetingEvent import Test.Wire.API.Golden.Manual.Pagination import Test.Wire.API.Golden.Manual.Presence import Test.Wire.API.Golden.Manual.Push @@ -155,6 +156,12 @@ tests = (testObject_Event_conversation_manual_2, "testObject_Event_conversation_manual_2.json"), (testObject_Event_conversation_manual_3, "testObject_Event_conversation_manual_3.json") ], + testGroup "MeetingEvent" $ + testObjects + [ (testObject_Event_meeting_create_manual_1, "testObject_Event_meeting_create_manual_1.json"), + (testObject_Event_meeting_update_manual_1, "testObject_Event_meeting_update_manual_1.json"), + (testObject_Event_meeting_delete_manual_1, "testObject_Event_meeting_delete_manual_1.json") + ], testGroup "GetPaginatedConversationIds" $ testObjects [ (testObject_GetPaginatedConversationIds_1, "testObject_GetPaginatedConversationIds_1.json"), diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MeetingEvent.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MeetingEvent.hs new file mode 100644 index 00000000000..ece72521210 --- /dev/null +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MeetingEvent.hs @@ -0,0 +1,59 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Wire.API.Golden.Manual.MeetingEvent where + +import Data.Domain (Domain (..)) +import Data.Id +import Data.Qualified (Qualified (..)) +import Data.Time +import Data.UUID qualified as UUID +import Imports +import Wire.API.Event.Meeting + +testObject_Event_meeting_create_manual_1 :: Event +testObject_Event_meeting_create_manual_1 = + Event + { evtType = Create, + evtMeeting = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001")), qDomain = Domain {_domainText = "example.com"}}, + evtConv = Qualified {qUnqualified = Id (fromJust (UUID.fromString "2126ea99-ca79-43ea-ad99-a59616468e8e")), qDomain = Domain {_domainText = "example.com"}}, + evtFrom = EventFromUser $ Qualified {qUnqualified = Id (fromJust (UUID.fromString "a471447c-aa30-4592-81b0-dec6c1c02bca")), qDomain = Domain {_domainText = "example.com"}}, + evtTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + evtTeam = Nothing + } + +testObject_Event_meeting_update_manual_1 :: Event +testObject_Event_meeting_update_manual_1 = + Event + { evtType = Update, + evtMeeting = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001")), qDomain = Domain {_domainText = "example.com"}}, + evtConv = Qualified {qUnqualified = Id (fromJust (UUID.fromString "2126ea99-ca79-43ea-ad99-a59616468e8e")), qDomain = Domain {_domainText = "example.com"}}, + evtFrom = EventFromUser $ Qualified {qUnqualified = Id (fromJust (UUID.fromString "a471447c-aa30-4592-81b0-dec6c1c02bca")), qDomain = Domain {_domainText = "example.com"}}, + evtTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + evtTeam = Nothing + } + +testObject_Event_meeting_delete_manual_1 :: Event +testObject_Event_meeting_delete_manual_1 = + Event + { evtType = Delete, + evtMeeting = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001")), qDomain = Domain {_domainText = "example.com"}}, + evtConv = Qualified {qUnqualified = Id (fromJust (UUID.fromString "2126ea99-ca79-43ea-ad99-a59616468e8e")), qDomain = Domain {_domainText = "example.com"}}, + evtFrom = EventFromUser $ Qualified {qUnqualified = Id (fromJust (UUID.fromString "a471447c-aa30-4592-81b0-dec6c1c02bca")), qDomain = Domain {_domainText = "example.com"}}, + evtTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + evtTeam = Nothing + } diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json new file mode 100644 index 00000000000..faaf77d4b6a --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json @@ -0,0 +1,19 @@ +{ + "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", + "qualified_conversation": { + "domain": "example.com", + "id": "2126ea99-ca79-43ea-ad99-a59616468e8e" + }, + "qualified_from": { + "domain": "example.com", + "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" + }, + "time": "2018-01-01T00:00:00.000Z", + "type": "meeting.create", + "via": "user" +} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json new file mode 100644 index 00000000000..5bae8ab62d0 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json @@ -0,0 +1,19 @@ +{ + "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", + "qualified_conversation": { + "domain": "example.com", + "id": "2126ea99-ca79-43ea-ad99-a59616468e8e" + }, + "qualified_from": { + "domain": "example.com", + "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" + }, + "time": "2018-01-01T00:00:00.000Z", + "type": "meeting.delete", + "via": "user" +} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json new file mode 100644 index 00000000000..e1754d221ef --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json @@ -0,0 +1,19 @@ +{ + "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", + "qualified_conversation": { + "domain": "example.com", + "id": "2126ea99-ca79-43ea-ad99-a59616468e8e" + }, + "qualified_from": { + "domain": "example.com", + "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" + }, + "time": "2018-01-01T00:00:00.000Z", + "type": "meeting.update", + "via": "user" +} diff --git a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs index f3651238321..026a393bbd3 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs @@ -86,9 +86,6 @@ testIsCellsConversationEvent = OtrMessageAdd -> isCellsConversationEvent e === False ProtocolUpdate -> isCellsConversationEvent e === False Typing -> isCellsConversationEvent e === False - MeetingCreate -> isCellsConversationEvent e === False - MeetingUpdate -> isCellsConversationEvent e === False - MeetingDelete -> isCellsConversationEvent e === False -------------------------------------------------------------------------------- -- Legacy conversion tests diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index 77cd01c4e9f..6cdabd410b0 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -105,6 +105,7 @@ library Wire.API.Event.Federation Wire.API.Event.Gundeck Wire.API.Event.LeaveReason + Wire.API.Event.Meeting Wire.API.Event.Team Wire.API.Event.WebSocketProtocol Wire.API.FederationStatus @@ -645,6 +646,7 @@ test-suite wire-api-golden-tests Test.Wire.API.Golden.Manual.ListUsersById Test.Wire.API.Golden.Manual.Login_user Test.Wire.API.Golden.Manual.LoginId_user + Test.Wire.API.Golden.Manual.MeetingEvent Test.Wire.API.Golden.Manual.MLSKeys Test.Wire.API.Golden.Manual.Pagination Test.Wire.API.Golden.Manual.Presence diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs index 4b5d62bab2c..8225f3469ec 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs @@ -42,7 +42,7 @@ import Polysemy.TinyLog qualified as TinyLog import System.Logger qualified as Log import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Role (roleNameWireAdmin) -import Wire.API.Event.Conversation qualified as ConvEvent +import Wire.API.Event.Meeting qualified as MeetingEvent import Wire.API.Meeting qualified as API import Wire.API.Push.V2 qualified as PushV2 import Wire.API.Routes.MultiTablePaging qualified as MultiTablePaging @@ -193,7 +193,7 @@ createMeetingImpl zUser newMeeting = do trial let qMeetingId = Qualified storedMeeting.id (tDomain zUser) - pushMeetingEvent zUser Nothing storedConv.localMembers (Qualified storedConv.id_ (tDomain zUser)) conversationTeamId (ConvEvent.EdMeetingCreate qMeetingId) + pushMeetingEvent zUser Nothing storedConv.localMembers (Qualified storedConv.id_ (tDomain zUser)) conversationTeamId MeetingEvent.Create qMeetingId pure $ storedMeetingToMeetingWithConversation zUser storedConv storedMeeting @@ -243,7 +243,7 @@ updateMeetingImpl zUser meetingId update validityPeriod = do update.endTime update.recurrence conv <- MaybeT $ getMeetingConversationOrFail meetingId updatedMeeting.conversationId - lift $ pushMeetingEvent zUser Nothing conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId (ConvEvent.EdMeetingUpdate meetingId) + lift $ pushMeetingEvent zUser Nothing conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId MeetingEvent.Update meetingId pure $ storedMeetingToMeetingWithConversation zUser conv updatedMeeting deleteMeetingImpl :: @@ -280,7 +280,7 @@ deleteMeetingImpl zUser connId meetingId validityPeriod = do void $ ConversationSubsystem.deleteLocalConversation zUser connId lConvId lift $ Store.deleteMeeting (qUnqualified meetingId) - lift $ pushMeetingEvent zUser (Just connId) conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId (ConvEvent.EdMeetingDelete meetingId) + lift $ pushMeetingEvent zUser (Just connId) conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId MeetingEvent.Delete meetingId pure $ isJust result getMeetingImpl :: @@ -349,20 +349,21 @@ pushMeetingEvent :: [LocalMember] -> Qualified ConvId -> Maybe TeamId -> - ConvEvent.EventData -> + MeetingEvent.EventType -> + Qualified MeetingId -> Sem r () -pushMeetingEvent lUser conn members qConvId mTeamId edata = do +pushMeetingEvent lUser conn members qConvId mTeamId meetingType qMeetingId = do now <- Now.get let evt = - ConvEvent.Event - { evtConv = qConvId, - evtSubConv = Nothing, + MeetingEvent.Event + { evtType = meetingType, + evtMeeting = qMeetingId, + evtConv = qConvId, evtFrom = - ConvEvent.EventFromUser + MeetingEvent.EventFromUser (Qualified (tUnqualified lUser) (tDomain lUser)), evtTime = now, - evtTeam = mTeamId, - evtData = edata + evtTeam = mTeamId } pushNotifications [ def diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index a177e9a8b39..a3f6580c45d 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -44,7 +44,7 @@ import Text.Email.Parser (unsafeEmailAddress) import Wire.API.Conversation (Access (InviteAccess, PrivateAccess), Conversation (metadata, qualifiedId), ConversationMetadata (cnvmAccess)) import Wire.API.Error (ErrorS) import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound)) -import Wire.API.Event.Conversation qualified as ConvEvent +import Wire.API.Event.Meeting qualified as MeetingEvent import Wire.API.Meeting qualified as API import Wire.API.Team.Feature import Wire.API.Team.Member (TeamMember, mkTeamMember) @@ -135,21 +135,12 @@ runTestStack now gen teams configs = . inMemoryMeetingsStoreInterpreter . interpretMeetingsSubsystem 3600 --- | Decode all 'Push' payloads that are conversation events carrying meeting --- lifecycle data. -extractMeetingEvents :: [Push] -> [ConvEvent.Event] +-- | Decode all 'Push' payloads that are meeting lifecycle events. Any push that +-- decodes as a 'MeetingEvent.Event' is one: conversation events use distinct +-- @type@ tags that the meeting 'EventType' enum rejects. +extractMeetingEvents :: [Push] -> [MeetingEvent.Event] extractMeetingEvents pushes = - [ e - | push <- pushes, - Success e <- [fromJSON (Object push.json)], - isMeetingEvent e - ] - where - isMeetingEvent e = case e.evtData of - ConvEvent.EdMeetingCreate _ -> True - ConvEvent.EdMeetingUpdate _ -> True - ConvEvent.EdMeetingDelete _ -> True - _ -> False + [e | push <- pushes, Success e <- [fromJSON (Object push.json)]] spec :: Spec spec = describe "MeetingsSubsystem.Interpreter" $ do @@ -1389,9 +1380,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do Right (meeting, pushes) -> do let events = extractMeetingEvents pushes length events `shouldBe` 1 - case (head events).evtData of - ConvEvent.EdMeetingCreate mid -> mid `shouldBe` meeting.meeting.id - other -> fail $ "expected EdMeetingCreate, got " <> show other + (head events).evtType `shouldBe` MeetingEvent.Create + (head events).evtMeeting `shouldBe` meeting.meeting.id it "emits a meeting.update event on successful update" $ do result <- @@ -1406,9 +1396,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do Right pushes -> do let events = extractMeetingEvents pushes length events `shouldBe` 1 - case (head events).evtData of - ConvEvent.EdMeetingUpdate _ -> pure () - other -> fail $ "expected EdMeetingUpdate, got " <> show other + (head events).evtType `shouldBe` MeetingEvent.Update it "emits a meeting.delete event on successful delete" $ do result <- @@ -1423,9 +1411,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do Right pushes -> do let events = extractMeetingEvents pushes length events `shouldBe` 1 - case (head events).evtData of - ConvEvent.EdMeetingDelete _ -> pure () - other -> fail $ "expected EdMeetingDelete, got " <> show other + (head events).evtType `shouldBe` MeetingEvent.Delete it "does not emit an event when updateMeeting fails (non-creator)" $ do result <- From e9c74d20440c879a4c70766fa5fc6e245fc4c05d Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Tue, 21 Jul 2026 23:24:27 +0200 Subject: [PATCH 022/113] WPB-27329: meetings read endpoints no longer 403 when feature disabled (#5353) --- .../wpb-27329-meetings-read-disabled | 1 + integration/test/Test/Meetings.hs | 45 +++++++++++ .../src/Wire/MeetingsSubsystem/Interpreter.hs | 80 +++++++++++-------- .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 8 +- 4 files changed, 96 insertions(+), 38 deletions(-) create mode 100644 changelog.d/1-api-changes/wpb-27329-meetings-read-disabled diff --git a/changelog.d/1-api-changes/wpb-27329-meetings-read-disabled b/changelog.d/1-api-changes/wpb-27329-meetings-read-disabled new file mode 100644 index 00000000000..db3e2cfc83d --- /dev/null +++ b/changelog.d/1-api-changes/wpb-27329-meetings-read-disabled @@ -0,0 +1 @@ +GET /meetings/list and GET /meetings/{domain}/{id} no longer return 403 invalid-op when the caller's team has the `meetings` feature disabled. The read endpoints now treat a disabled feature as "no meetings": GET /meetings/list returns 200 [], and GET /meetings/{domain}/{id} returns 404 meeting-not-found. Write operations (create, update, delete, invitation mutations) still return 403 invalid-op when the feature is disabled. Previously these read endpoints returned an undocumented 403 invalid-op for members of teams with the meetings feature disabled. diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs index a1abf5525b0..c037f062de3 100644 --- a/integration/test/Test/Meetings.hs +++ b/integration/test/Test/Meetings.hs @@ -177,6 +177,51 @@ testMeetingsConfigDisabledBlocksCreate = do postMeetings owner newMeeting >>= assertLabel 403 "invalid-op" +-- | Read endpoints (@GET /meetings/list@, @GET /meetings/{domain}/{id}@) treat a +-- team with the @meetings@ feature disabled as "no meetings" (list -> @200 []@, +-- get -> @404 meeting-not-found@) instead of @403 invalid-op@, while write +-- endpoints (@POST /meetings@) keep the hard gate and still return @403 +-- invalid-op@. See WPB-27329. +testMeetingsReadsWhenDisabled :: (HasCallStack) => App () +testMeetingsReadsWhenDisabled = do + (owner, tid, _members) <- createTeam OwnDomain 1 + now <- liftIO getCurrentTime + let startTime = addUTCTime 3600 now + endTime = addUTCTime 7200 now + newMeeting = defaultMeetingJson "test meeting" startTime endTime [] + + -- Enabled (default): create a meeting to use as the read target later. + meeting <- postMeetings owner newMeeting >>= getJSON 201 + (meetingId, domain) <- getMeetingIdAndDomain meeting + + -- Positive control while enabled: the created meeting is directly findable. + getMeeting owner domain meetingId >>= assertStatus 200 + + -- Disable the meetings feature for the team. + let disabled = object ["status" .= "disabled", "lockStatus" .= "unlocked"] + I.setTeamFeatureConfig owner tid "meetings" disabled >>= assertStatus 200 + + -- Read paths treat a disabled feature as "no meetings": list -> 200 [], get -> 404. + getMeetingsList owner `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + meetings <- resp.json & asList + shouldBeEmpty meetings + + getMeeting owner domain meetingId >>= assertLabel 404 "meeting-not-found" + + -- Write paths keep the hard gate: create -> 403 invalid-op (unchanged behavior). + postMeetings owner newMeeting >>= assertLabel 403 "invalid-op" + + -- Re-enable: the previously-created meeting is readable again. + let enabled = object ["status" .= "enabled", "lockStatus" .= "unlocked"] + I.setTeamFeatureConfig owner tid "meetings" enabled >>= assertStatus 200 + + listResp2 <- getMeetingsList owner + assertSuccess listResp2 + meetings2 <- listResp2.json & asList + fetchedIds <- forM meetings2 $ \m -> m %. "qualified_id" %. "id" >>= asString + (meetingId `elem` fetchedIds) `shouldMatch` True + -- Test that creating a meeting with a start time in the past is rejected testMeetingCreatePastStartTime :: (HasCallStack) => App () testMeetingCreatePastStartTime = do diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs index 8225f3469ec..abc9d884300 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs @@ -84,13 +84,23 @@ checkMeetingsEnabled :: ) => Maybe TeamId -> Sem r () -checkMeetingsEnabled maybeTeamId = do +checkMeetingsEnabled maybeTeamId = + unlessM (meetingsFeatureEnabled maybeTeamId) $ + throw MeetingsFeatureDisabled + +-- | Like 'checkMeetingsEnabled' but returns the resolved status instead of +-- throwing. Used by read paths (list, get) that treat a disabled feature as +-- "no meetings" rather than as a forbidden operation. +meetingsFeatureEnabled :: + (Member FeaturesConfigSubsystem r) => + Maybe TeamId -> + Sem r Bool +meetingsFeatureEnabled maybeTeamId = case maybeTeamId of - Nothing -> pure () + Nothing -> pure True Just teamId -> do meetingFeature <- getFeatureForTeam @_ @MeetingsConfig teamId - unless (meetingFeature.status == FeatureStatusEnabled) $ - throw MeetingsFeatureDisabled + pure (meetingFeature.status == FeatureStatusEnabled) interpretMeetingsSubsystem :: ( Member Store.MeetingsStore r, @@ -288,7 +298,6 @@ getMeetingImpl :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, - Member (Error MeetingError) r, Member Now r ) => Local UserId -> @@ -297,23 +306,24 @@ getMeetingImpl :: Sem r (Maybe API.Meeting) getMeetingImpl zUser meetingId validityPeriod = do maybeTeamId <- TeamSubsystem.internalGetOneUserTeam (tUnqualified zUser) - checkMeetingsEnabled maybeTeamId - -- Get meeting from store - runMaybeT $ do - storedMeeting <- MaybeT $ Store.getMeeting (qUnqualified meetingId) - now <- lift Now.get - let cutoff = addUTCTime (negate validityPeriod) now - guard $ isAlive cutoff storedMeeting - guard $ qDomain meetingId == tDomain zUser - -- Check authorization: user must be creator OR member of the associated conversation - let isCreator = storedMeeting.creator == tUnqualified zUser - if isCreator - then pure $ storedMeetingToMeeting (tDomain zUser) storedMeeting - else do - -- Check if user is a member of the conversation - let convId = storedMeeting.conversationId - void $ MaybeT $ ConversationSubsystem.internalGetLocalMember convId (tUnqualified zUser) - pure $ storedMeetingToMeeting (tDomain zUser) storedMeeting -- User is a member, authorized + enabled <- meetingsFeatureEnabled maybeTeamId + if enabled + then runMaybeT $ do + storedMeeting <- MaybeT $ Store.getMeeting (qUnqualified meetingId) + now <- lift Now.get + let cutoff = addUTCTime (negate validityPeriod) now + guard $ isAlive cutoff storedMeeting + guard $ qDomain meetingId == tDomain zUser + -- Check authorization: user must be creator OR member of the associated conversation + let isCreator = storedMeeting.creator == tUnqualified zUser + if isCreator + then pure $ storedMeetingToMeeting (tDomain zUser) storedMeeting + else do + -- Check if user is a member of the conversation + let convId = storedMeeting.conversationId + void $ MaybeT $ ConversationSubsystem.internalGetLocalMember convId (tUnqualified zUser) + pure $ storedMeetingToMeeting (tDomain zUser) storedMeeting -- User is a member, authorized + else pure Nothing -- | Look up the 'StoredConversation' associated with a meeting. When the -- conversation cannot be found (a data-integrity anomaly), a warning is logged @@ -415,7 +425,6 @@ listMeetingsImpl :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, - Member (Error MeetingError) r, Member Now r ) => Local UserId -> @@ -423,17 +432,20 @@ listMeetingsImpl :: Sem r [API.Meeting] listMeetingsImpl zUser validityPeriod = do maybeTeamId <- TeamSubsystem.internalGetOneUserTeam (tUnqualified zUser) - checkMeetingsEnabled maybeTeamId - now <- Now.get - let cutoff = addUTCTime (negate validityPeriod) now - -- List all meetings created by the user - createdMeetings <- Store.listMeetingsByUser (tUnqualified zUser) cutoff - -- Loop over local conversations accessible by the user, then filter to only keep meetings. - memberMeetings <- getAllMemberMeetings zUser cutoff - -- Combine and deduplicate - let allMeetings = map (storedMeetingToMeeting (tDomain zUser)) createdMeetings <> memberMeetings - uniqueMeetings = Map.elems $ Map.fromList [(m.id, m) | m <- allMeetings] - pure uniqueMeetings + enabled <- meetingsFeatureEnabled maybeTeamId + if enabled + then do + now <- Now.get + let cutoff = addUTCTime (negate validityPeriod) now + -- List all meetings created by the user + createdMeetings <- Store.listMeetingsByUser (tUnqualified zUser) cutoff + -- Loop over local conversations accessible by the user, then filter to only keep meetings. + memberMeetings <- getAllMemberMeetings zUser cutoff + -- Combine and deduplicate + let allMeetings = map (storedMeetingToMeeting (tDomain zUser)) createdMeetings <> memberMeetings + uniqueMeetings = Map.elems $ Map.fromList [(m.id, m) | m <- allMeetings] + pure uniqueMeetings + else pure [] getAllMemberMeetings :: ( Member Store.MeetingsStore r, diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index a3f6580c45d..ebaf52f684e 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -1256,7 +1256,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do result `shouldBe` Left MeetingsFeatureDisabled - it "throws MeetingsFeatureDisabled on getMeeting for team user with meetings disabled" $ do + it "returns Nothing on getMeeting for team user with meetings disabled" $ do result <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ createMeeting zUserTeam newMeeting @@ -1268,7 +1268,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ getMeeting zUserTeam meeting.meeting.id - result2 `shouldBe` Left MeetingsFeatureDisabled + result2 `shouldBe` Right Nothing it "throws MeetingsFeatureDisabled on updateMeeting for team user with meetings disabled" $ do result <- @@ -1298,12 +1298,12 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do result2 `shouldBe` Left MeetingsFeatureDisabled - it "throws MeetingsFeatureDisabled on listMeetings for team user with meetings disabled" $ do + it "returns [] on listMeetings for team user with meetings disabled" $ do result <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ listMeetings zUserTeam - result `shouldBe` Left MeetingsFeatureDisabled + result `shouldBe` Right [] it "throws MeetingsFeatureDisabled on addInvitedEmails for team user with meetings disabled" $ do result <- From 538d5168d85f104ac99948706d76f0d4a191103d Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Wed, 22 Jul 2026 16:51:54 +0200 Subject: [PATCH 023/113] WPB-25544 fix: stealth users are searchable via federated search (#5282) --- changelog.d/3-bug-fixes/WPB-25544 | 1 + integration/test/Test/Search.hs | 23 +++++++++++++++++++ .../brig/src/Brig/User/Search/SearchIndex.hs | 7 +++++- 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 changelog.d/3-bug-fixes/WPB-25544 diff --git a/changelog.d/3-bug-fixes/WPB-25544 b/changelog.d/3-bug-fixes/WPB-25544 new file mode 100644 index 00000000000..14a7d1a7de8 --- /dev/null +++ b/changelog.d/3-bug-fixes/WPB-25544 @@ -0,0 +1 @@ +Users marked non-searchable are no longer returned across federation. diff --git a/integration/test/Test/Search.hs b/integration/test/Test/Search.hs index 65a899179e9..a0986828555 100644 --- a/integration/test/Test/Search.hs +++ b/integration/test/Test/Search.hs @@ -578,6 +578,29 @@ testUserSearchable = do docs <- resp.json %. "documents" >>= asList f docs +testStealthUsersWithFederation :: App () +testStealthUsersWithFederation = do + ownDomain <- asString OwnDomain + otherDomain <- asString OtherDomain + void $ BrigI.createFedConn OwnDomain (BrigI.FedConn otherDomain "full_search" Nothing) + void $ BrigI.createFedConn OtherDomain (BrigI.FedConn ownDomain "full_search" Nothing) + + (searcher, _, _) <- createTeam OwnDomain 1 + (owner, tid, searchee : _) <- createTeam OtherDomain 2 + searcheeId <- objId searchee + let searchTerm = "stealth-federation-user" + + assertSuccess =<< GalleyI.setTeamFeatureStatus OtherDomain tid "searchVisibilityInbound" "enabled" + BrigP.putSelf searchee (def {BrigP.name = Just searchTerm}) >>= assertSuccess + BrigI.refreshIndex OtherDomain + + assertCanFind searcher searchee searchTerm OtherDomain + + BrigP.setUserSearchable owner searcheeId False >>= assertSuccess + BrigI.refreshIndex OtherDomain + + assertCannotFind searcher searchee searchTerm OtherDomain + testSuspendedUserSearch :: (HasCallStack) => App () testSuspendedUserSearch = do [searcher, searchee] <- replicateM 2 $ randomUser OwnDomain def diff --git a/services/brig/src/Brig/User/Search/SearchIndex.hs b/services/brig/src/Brig/User/Search/SearchIndex.hs index d97167c87a5..360baa24430 100644 --- a/services/brig/src/Brig/User/Search/SearchIndex.hs +++ b/services/brig/src/Brig/User/Search/SearchIndex.hs @@ -157,7 +157,12 @@ mkUserQuery setting q = ( ES.Filter . ES.QueryBoolQuery $ boolQuery - { ES.boolQueryMustNotMatch = maybeToList $ matchSelf setting, + { ES.boolQueryMustNotMatch = + maybeToList (matchSelf setting) + <> + -- Federated search must respect the same "searchable" flag as + -- local contact search. + [ES.TermQuery (ES.Term "searchable" "false") Nothing], ES.boolQueryMustMatch = [ restrictSearchSpaceByTeam setting, restrictSearchSpaceByUserType setting.types, From 0da488448f0ead18525c2a0b0ef084437265e70c Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Wed, 22 Jul 2026 17:05:14 +0200 Subject: [PATCH 024/113] WPB-24669 [fix] upload of files with umlaut when audit log enabled (#5359) --- changelog.d/3-bug-fixes/WPB-24669 | 1 + services/cargohold/cargohold.cabal | 1 + services/cargohold/src/CargoHold/S3.hs | 14 ++++- services/cargohold/test/unit/Main.hs | 3 +- .../test/unit/Test/CargoHold/S3Test.hs | 61 +++++++++++++++++++ 5 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 changelog.d/3-bug-fixes/WPB-24669 create mode 100644 services/cargohold/test/unit/Test/CargoHold/S3Test.hs diff --git a/changelog.d/3-bug-fixes/WPB-24669 b/changelog.d/3-bug-fixes/WPB-24669 new file mode 100644 index 00000000000..fc94cf60f77 --- /dev/null +++ b/changelog.d/3-bug-fixes/WPB-24669 @@ -0,0 +1 @@ +Fixed asset uploads with non-ASCII filenames when audit logging is enabled. Audit-log metadata is now percent-encoded before being stored in S3 metadata headers and decoded when read back. diff --git a/services/cargohold/cargohold.cabal b/services/cargohold/cargohold.cabal index ecc35c1f4a5..64ebd3d91d5 100644 --- a/services/cargohold/cargohold.cabal +++ b/services/cargohold/cargohold.cabal @@ -308,6 +308,7 @@ test-suite cargohold-tests other-modules: Test.CargoHold.API.AuditLogTest Test.CargoHold.API.LogJSON + Test.CargoHold.S3Test default-extensions: AllowAmbiguousTypes diff --git a/services/cargohold/src/CargoHold/S3.hs b/services/cargohold/src/CargoHold/S3.hs index a8a5347f51e..58d511db2a3 100644 --- a/services/cargohold/src/CargoHold/S3.hs +++ b/services/cargohold/src/CargoHold/S3.hs @@ -22,6 +22,8 @@ module CargoHold.S3 ( S3AssetKey, S3AssetMeta (..), AssetAuditLogMetadata (..), + setAmzAuditLogMetadata, + getAmzAuditLogMetadata, uploadV3, downloadV3, getMetadataV3, @@ -74,6 +76,7 @@ import qualified Data.Text.Encoding as Text import Data.Time.Clock import qualified Data.UUID as UUID import Imports +import qualified Network.HTTP.Types.URI as HTTPURI import qualified System.Logger.Class as Log import System.Logger.Message (msg, val, (.=), (~~)) import Test.QuickCheck (Arbitrary (..)) @@ -383,7 +386,8 @@ setAmzAuditLogMetadata :: AssetAuditLogMetadata -> (Text, Text) setAmzAuditLogMetadata t = (hAmzWireMetadata, encodeAuditLogMetadata t) where encodeAuditLogMetadata :: AssetAuditLogMetadata -> Text - encodeAuditLogMetadata meta = Text.decodeUtf8 (LBS.toStrict (A.encode meta)) + encodeAuditLogMetadata meta = + decodeLatin1 . HTTPURI.urlEncode False . LBS.toStrict $ A.encode meta ------------------------------------------------------------------------------- -- S3 Metadata Getters @@ -418,7 +422,13 @@ getAmzAuditLogMetadata :: [(Text, Text)] -> Maybe AssetAuditLogMetadata getAmzAuditLogMetadata = lookupCI hAmzWireMetadata >=> parseAuditLogMetadata where parseAuditLogMetadata :: Text -> Maybe AssetAuditLogMetadata - parseAuditLogMetadata t = A.decode $ fromStrict $ encodeUtf8 t + parseAuditLogMetadata t = parseJSON t <|> parseJSONBytes (HTTPURI.urlDecode False (encodeUtf8 t)) + + parseJSON :: Text -> Maybe AssetAuditLogMetadata + parseJSON = parseJSONBytes . encodeUtf8 + + parseJSONBytes :: ByteString -> Maybe AssetAuditLogMetadata + parseJSONBytes = A.decode . fromStrict ------------------------------------------------------------------------------- -- Utilities diff --git a/services/cargohold/test/unit/Main.hs b/services/cargohold/test/unit/Main.hs index 0a0dd4f77c8..33ddc0743ab 100644 --- a/services/cargohold/test/unit/Main.hs +++ b/services/cargohold/test/unit/Main.hs @@ -19,7 +19,8 @@ module Main (main) where import Imports import qualified Test.CargoHold.API.AuditLogTest as AuditLog +import qualified Test.CargoHold.S3Test as S3 import Test.Tasty main :: IO () -main = defaultMain (testGroup "Cargohold Unit" [AuditLog.tests]) +main = defaultMain (testGroup "Cargohold Unit" [AuditLog.tests, S3.tests]) diff --git a/services/cargohold/test/unit/Test/CargoHold/S3Test.hs b/services/cargohold/test/unit/Test/CargoHold/S3Test.hs new file mode 100644 index 00000000000..bf4d5917622 --- /dev/null +++ b/services/cargohold/test/unit/Test/CargoHold/S3Test.hs @@ -0,0 +1,61 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.CargoHold.S3Test (tests) where + +import CargoHold.S3 (AssetAuditLogMetadata (..), getAmzAuditLogMetadata, setAmzAuditLogMetadata) +import qualified Data.Aeson as A +import qualified Data.ByteString as BS +import qualified Data.ByteString.Lazy as LBS +import Data.Text.Encoding (decodeUtf8, encodeUtf8) +import Imports +import Test.Tasty +import Test.Tasty.QuickCheck as QC + +tests :: TestTree +tests = + testGroup + "CargoHold.S3" + [ QC.testProperty + "audit-log metadata header is ASCII for filenames with umlauts" + propAuditLogMetadataHeaderIsAscii, + QC.testProperty + "audit-log metadata percent-encode/decode roundtrips for non-ASCII filenames" + propAuditLogMetadataPercentRoundtrip, + QC.testProperty + "legacy raw audit-log metadata preserves percent escapes" + propLegacyRawAuditLogMetadataPreservesPercentEscapes + ] + +propAuditLogMetadataPercentRoundtrip :: AssetAuditLogMetadata -> QC.Property +propAuditLogMetadataPercentRoundtrip metadata = + let meta' = metadata {filename = "Mönchsjochhütte"} + (k, v) = setAmzAuditLogMetadata meta' + in getAmzAuditLogMetadata [(k, v)] QC.=== Just meta' + +propAuditLogMetadataHeaderIsAscii :: AssetAuditLogMetadata -> QC.Property +propAuditLogMetadataHeaderIsAscii metadata = + let (_, headerValue) = + setAmzAuditLogMetadata metadata {filename = "Mönchsjochhütte"} + in QC.counterexample ("non-ASCII S3 metadata header: " <> show headerValue) $ + BS.all (< 128) (encodeUtf8 headerValue) + +propLegacyRawAuditLogMetadataPreservesPercentEscapes :: AssetAuditLogMetadata -> QC.Property +propLegacyRawAuditLogMetadataPreservesPercentEscapes metadata = + let expected = metadata {filename = "%2F and %20 stay unchanged"} + rawJSON = decodeUtf8 (LBS.toStrict (A.encode expected)) + in getAmzAuditLogMetadata [("wire-metadata", rawJSON)] QC.=== Just expected From a8892f6e6868cfc9903757b9f0e0bc4704e38a3d Mon Sep 17 00:00:00 2001 From: Sven Tennie Date: Wed, 22 Jul 2026 17:41:31 +0200 Subject: [PATCH 025/113] Replace gone bitnami image cache (#5360) `public.ecr.aws/bitnami/` is gone: https://aws.amazon.com/blogs/containers/bitnami-image-removal-from-ecr-public/ docker.io has tight rate-limiting. So, in lieu of better options, we're resorting to our own cache. --- .../wire-image-mirror-for-integration-tests | 5 +++++ .../{bitnami.yaml => wire-image-mirror.yaml} | 2 +- hack/helmfile.yaml.gotmpl | 20 +++++++++++++------ 3 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 changelog.d/5-internal/wire-image-mirror-for-integration-tests rename hack/helm_vars/{bitnami.yaml => wire-image-mirror.yaml} (81%) diff --git a/changelog.d/5-internal/wire-image-mirror-for-integration-tests b/changelog.d/5-internal/wire-image-mirror-for-integration-tests new file mode 100644 index 00000000000..b6108c22388 --- /dev/null +++ b/changelog.d/5-internal/wire-image-mirror-for-integration-tests @@ -0,0 +1,5 @@ +Use wire image mirror for integration tests as `public.ecr.aws/bitnami/` is no +longer available +(https://aws.amazon.com/blogs/containers/bitnami-image-removal-from-ecr-public/). +Docker Hub has strict rate-limiting. So, in lieu of better options, we now use +our own image cache at `quay.io/wire/mirror-images`. diff --git a/hack/helm_vars/bitnami.yaml b/hack/helm_vars/wire-image-mirror.yaml similarity index 81% rename from hack/helm_vars/bitnami.yaml rename to hack/helm_vars/wire-image-mirror.yaml index 6b7db99570f..4ec1bce32be 100644 --- a/hack/helm_vars/bitnami.yaml +++ b/hack/helm_vars/wire-image-mirror.yaml @@ -1,6 +1,6 @@ global: # The default is to use docker hub, which has very strict rate limiting. This # comes in the way of testing, specially when running flake-news. - imageRegistry: public.ecr.aws + imageRegistry: quay.io/wire/mirror-images security: allowInsecureImages: true diff --git a/hack/helmfile.yaml.gotmpl b/hack/helmfile.yaml.gotmpl index bb1bedad9bc..09c825f9c31 100644 --- a/hack/helmfile.yaml.gotmpl +++ b/hack/helmfile.yaml.gotmpl @@ -118,7 +118,7 @@ releases: namespace: '{{ .Values.namespace1 }}' chart: '../.local/charts/redis-ephemeral' values: - - './helm_vars/bitnami.yaml' + - './helm_vars/wire-image-mirror.yaml' - './helm_vars/redis-ephemeral/values.yaml' needs: - certs @@ -151,7 +151,7 @@ releases: namespace: '{{ .Values.namespace2 }}' chart: '../.local/charts/redis-ephemeral' values: - - './helm_vars/bitnami.yaml' + - './helm_vars/wire-image-mirror.yaml' - './helm_vars/redis-ephemeral/values.yaml' needs: - certs @@ -164,8 +164,10 @@ releases: namespace: "{{ .Values.namespace1 }}" chart: "bitnami/postgresql" values: - - './helm_vars/bitnami.yaml' + - './helm_vars/wire-image-mirror.yaml' - './helm_vars/postgresql/values.yaml.gotmpl' + - image: + repository: postgresql - primary: initdb: scripts: @@ -184,8 +186,10 @@ releases: namespace: "{{ .Values.namespace2 }}" chart: "bitnami/postgresql" values: - - './helm_vars/bitnami.yaml' + - './helm_vars/wire-image-mirror.yaml' - './helm_vars/postgresql/values.yaml.gotmpl' + - image: + repository: postgresql - primary: initdb: scripts: @@ -253,16 +257,20 @@ releases: chart: 'bitnami/rabbitmq' version: '16.0.14' values: - - './helm_vars/bitnami.yaml' + - './helm_vars/wire-image-mirror.yaml' - './helm_vars/rabbitmq/values.yaml.gotmpl' + - image: + repository: rabbitmq - name: 'rabbitmq' namespace: '{{ .Values.namespace2 }}' chart: 'bitnami/rabbitmq' version: '16.0.14' values: - - './helm_vars/bitnami.yaml' + - './helm_vars/wire-image-mirror.yaml' - './helm_vars/rabbitmq/values.yaml.gotmpl' + - image: + repository: rabbitmq - name: 'ingress' namespace: '{{ .Values.namespace1 }}' From 847f572f0593c16b9da83712c834c48eeb2d5bcd Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 23 Jul 2026 09:05:14 +0200 Subject: [PATCH 026/113] fix static swagger-v16.json file (#5365) --- services/brig/docs/swagger-v16.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/brig/docs/swagger-v16.json b/services/brig/docs/swagger-v16.json index b79c0837518..045397da930 100644 --- a/services/brig/docs/swagger-v16.json +++ b/services/brig/docs/swagger-v16.json @@ -1 +1 @@ -{"components":{"schemas":{"ASCII":{"example":"aGVsbG8","type":"string"},"AcceptTeamInvitation_Nzg5NzI3MjA2":{"description":"Accept an invitation to join a team on Wire.","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"password":{"description":"The user account password.","maxLength":1024,"minLength":6,"type":"string"}},"required":["code","password"],"type":"object"},"AccessRoleLegacy_LTYwOTAxMDI1":{"deprecated":true,"description":"Deprecated, please use access_role_v2","enum":["private","team","activated","non_activated"],"type":"string"},"AccessRole_Mzk3MDYzMzcw":{"description":"Which users/services can join conversations. This replaces legacy access roles and allows a more fine grained configuration of access roles, and in particular a separation of guest and services access.\n\nThis field is optional. If it is not present, the default will be `[team_member, non_team_member, service]`. Please note that an empty list is not allowed when creating a new conversation.","enum":["team_member","non_team_member","guest","service"],"type":"string"},"AccessTokenType_LTgyOTY0NDE5":{"enum":["DPoP"],"type":"string"},"AccessToken_ODIyMTczMjMw":{"properties":{"access_token":{"description":"The opaque access token string","type":"string"},"expires_in":{"description":"The number of seconds this token is valid","type":"integer"},"token_type":{"$ref":"#/components/schemas/TokenType_NTkyMzk4MjIz"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","access_token","token_type","expires_in"],"type":"object"},"Access_NjkyMzE5ODc0":{"description":"How users can join conversations","enum":["private","invite","link","code"],"type":"string"},"AccountStatus_NzkzNDU1ODU5":{"enum":["active","suspended","deleted","ephemeral","pending-invitation"],"type":"string"},"Action":{"enum":["add_conversation_member","remove_conversation_member","modify_conversation_name","modify_conversation_message_timer","modify_conversation_receipt_mode","modify_conversation_access","modify_other_conversation_member","leave_conversation","delete_conversation","modify_add_permission"],"type":"string"},"Activate_MzUzNzIxODUw":{"description":"Data for an activation request.","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"dryrun":{"description":"At least one of key, email, or phone has to be present while key takes precedence over email, and email takes precedence over phone. Whether to perform a dryrun, i.e. to only check whether activation would succeed. Dry-runs never issue access cookies or tokens on success but failures still count towards the maximum failure count.","type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"key":{"$ref":"#/components/schemas/ASCII"}},"required":["code","dryrun"],"type":"object"},"ActivationResponse_LTIyOTY5NDE3":{"description":"Response body of a successful activation request","properties":{"email":{"$ref":"#/components/schemas/Email"},"first":{"description":"Whether this is the first successful activation (i.e. account activation).","type":"boolean"},"sso_id":{"$ref":"#/components/schemas/UserSSOId"}},"type":"object"},"AddBotResponse_ODA5MzA2NTA1":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"client":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"event":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"}},"required":["id","client","name","accent_id","assets","event"],"type":"object"},"AddBot_NjI0ODkyODk3":{"properties":{"locale":{"$ref":"#/components/schemas/Locale"},"provider":{"$ref":"#/components/schemas/UUID"},"service":{"$ref":"#/components/schemas/UUID"}},"required":["provider","service"],"type":"object"},"AddPermissionUpdate_LTU3MzEwOTY4":{"description":"The action of changing the permission to add members to a channel","properties":{"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"}},"required":["add_permission"],"type":"object"},"AddPermission_LTE1MzgzNzE3":{"enum":["admins","everyone"],"type":"string"},"AllowedGlobalOperationsConfig_MzAwOTU1MDkx":{"properties":{"mlsConversationReset":{"type":"boolean"}},"required":["mlsConversationReset"],"type":"object"},"Alpha_LTE4NDUxNDQ4":{"description":"ISO 4217 alphabetic codes. This is only stored by the backend, not processed. It can be removed once billing supports currency changes after team creation.","enum":["AED","AFN","ALL","AMD","ANG","AOA","ARS","AUD","AWG","AZN","BAM","BBD","BDT","BGN","BHD","BIF","BMD","BND","BOB","BOV","BRL","BSD","BTN","BWP","BYN","BZD","CAD","CDF","CHE","CHF","CHW","CLF","CLP","CNY","COP","COU","CRC","CUC","CUP","CVE","CZK","DJF","DKK","DOP","DZD","EGP","ERN","ETB","EUR","FJD","FKP","GBP","GEL","GHS","GIP","GMD","GNF","GTQ","GYD","HKD","HNL","HRK","HTG","HUF","IDR","ILS","INR","IQD","IRR","ISK","JMD","JOD","JPY","KES","KGS","KHR","KMF","KPW","KRW","KWD","KYD","KZT","LAK","LBP","LKR","LRD","LSL","LYD","MAD","MDL","MGA","MKD","MMK","MNT","MOP","MRO","MUR","MVR","MWK","MXN","MXV","MYR","MZN","NAD","NGN","NIO","NOK","NPR","NZD","OMR","PAB","PEN","PGK","PHP","PKR","PLN","PYG","QAR","RON","RSD","RUB","RWF","SAR","SBD","SCR","SDG","SEK","SGD","SHP","SLL","SOS","SRD","SSP","STD","SVC","SYP","SZL","THB","TJS","TMT","TND","TOP","TRY","TTD","TWD","TZS","UAH","UGX","USD","USN","UYI","UYU","UZS","VEF","VND","VUV","WST","XAF","XAG","XAU","XBA","XBB","XBC","XBD","XCD","XDR","XOF","XPD","XPF","XPT","XSU","XTS","XUA","XXX","YER","ZAR","ZMW","ZWL"],"example":"EUR","type":"string"},"AppInfo_MjgwNTkwOTUz":{"properties":{"category":{"description":"Category name (if uncertain, pick \"other\")","type":"string"},"description":{"maxLength":300,"minLength":0,"type":"string"}},"required":["category","description"],"type":"object"},"AppLockConfigB_Covered_Identity_NDIxOTc2Njkz":{"properties":{"enforceAppLock":{"type":"boolean"},"inactivityTimeoutSecs":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["enforceAppLock","inactivityTimeoutSecs"],"type":"object"},"ApproveLegalHoldForUserRequest_NjEyNzYyMTIx":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"AssetKey":{"description":"S3 asset key for an icon image with retention information.","example":"3-1-47de4580-ae51-4650-acbb-d10c028cb0ac","type":"string"},"AssetSize_OTAwMDA3ODY2":{"enum":["preview","complete"],"type":"string"},"AssetSource":{},"Asset_LTIyMjc1NDEz":{"properties":{"key":{"$ref":"#/components/schemas/AssetKey"},"size":{"$ref":"#/components/schemas/AssetSize_OTAwMDA3ODY2"},"type":{"$ref":"#/components/schemas/MTYxOTI3NjM3"}},"required":["key","type"],"type":"object"},"Asset_Qualified_AssetKey_MzU1MjMxNTA5":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"expires":{"$ref":"#/components/schemas/UTCTimeMillis"},"key":{"$ref":"#/components/schemas/AssetKey"},"token":{"$ref":"#/components/schemas/ASCII"}},"required":["key","domain"],"type":"object"},"AuthSFTServer_LTY5MzcyOTE0":{"description":"Inspired by WebRTC 'RTCIceServer' object, contains details of SFT servers","properties":{"credential":{"$ref":"#/components/schemas/ASCII"},"urls":{"description":"Array containing exactly one SFT server address of the form 'https://:'","items":{"$ref":"#/components/schemas/HttpsUrl"},"type":"array"},"username":{"$ref":"#/components/schemas/SFTUsername"}},"required":["urls"],"type":"object"},"AuthnRequest":{"properties":{"iD":{"$ref":"#/components/schemas/Id_AuthnRequest"},"issueInstant":{"$ref":"#/components/schemas/Time"},"issuer":{"$ref":"#/components/schemas/URI"},"nameIDPolicy":{"$ref":"#/components/schemas/NameIdPolicy"}},"required":["iD","issueInstant","issuer"],"type":"object"},"Base64ByteString":{"example":"ZXhhbXBsZQo=","type":"string"},"Base64URLByteString":{"example":"ZXhhbXBsZQo=","type":"string"},"BaseProtocolTag_LTM0MDE1NTEx":{"enum":["proteus","mls"],"type":"string"},"BindingNewTeamUser_LTY0MDQxMDEw":{"properties":{"currency":{"$ref":"#/components/schemas/Alpha_LTE4NDUxNDQ4"},"icon":{"$ref":"#/components/schemas/Icon"},"icon_key":{"description":"The decryption key for the team icon S3 asset","maxLength":256,"minLength":1,"type":"string"},"name":{"description":"team name","maxLength":256,"minLength":1,"type":"string"}},"required":["name","icon"],"type":"object"},"BotConvView_LTYzMjIzMjQz":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"members":{"items":{"$ref":"#/components/schemas/OtherMember_LTgzNzE2MTk4"},"type":"array"},"name":{"type":"string"}},"required":["id","members"],"type":"object"},"BotUserView_LTE2MTkwMTcw":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"handle":{"$ref":"#/components/schemas/Handle"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"team":{"$ref":"#/components/schemas/UUID"}},"required":["id","name","accent_id"],"type":"object"},"CellsBackend_LTE1Nzg3NzQ2":{"properties":{"url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["url"],"type":"object"},"CellsCollaboraStatus_MTgzNTQyNzUz":{"properties":{"enabled":{"type":"boolean"}},"required":["enabled"],"type":"object"},"CellsCollabora_LTMzNDA5MDIz":{"properties":{"edition":{"$ref":"#/components/schemas/CollaboraEdition_LTg2NDA1NDQ4"}},"required":["edition"],"type":"object"},"CellsConfigB_Covered_Identity_LTE1NzkwOTcz":{"example":{"channels":{"default":"enabled","enabled":true},"collabora":{"enabled":false},"groups":{"default":"enabled","enabled":true},"metadata":{"namespaces":{"usermetaTags":{"allowFreeValues":true,"defaultValues":[]}}},"one2one":{"default":"enabled","enabled":true},"publicLinks":{"enableFiles":true,"enableFolders":true,"enforceExpirationDefault":0,"enforceExpirationMax":0,"enforcePassword":false},"storage":{"perFileQuotaBytes":"100000000","recycle":{"allowSkip":false,"autoPurgeDays":30,"disable":false}},"users":{"externals":true,"guests":false}},"properties":{"channels":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"collabora":{"$ref":"#/components/schemas/CellsCollaboraStatus_MTgzNTQyNzUz"},"groups":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"metadata":{"$ref":"#/components/schemas/CellsMetadata_LTY1OTM5MTM0"},"one2one":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"publicLinks":{"$ref":"#/components/schemas/CellsPublicLinks_MjgxMzQ3Mzk4"},"storage":{"$ref":"#/components/schemas/CellsConfigStorage_LTM0NDMwODM4"},"users":{"$ref":"#/components/schemas/CellsUsers_LTQ4NTEyODA1"}},"required":["channels","groups","one2one","users","collabora","publicLinks","storage","metadata"],"type":"object"},"CellsConfigStorage_LTM0NDMwODM4":{"properties":{"perFileQuotaBytes":{"type":"string"},"recycle":{"$ref":"#/components/schemas/CellsRecycle_LTQxMTg3NTkx"}},"required":["perFileQuotaBytes","recycle"],"type":"object"},"CellsInternalConfigB_Covered_Identity_LTUzMDkwOTAz":{"properties":{"backend":{"$ref":"#/components/schemas/CellsBackend_LTE1Nzg3NzQ2"},"collabora":{"$ref":"#/components/schemas/CellsCollabora_LTMzNDA5MDIz"},"storage":{"$ref":"#/components/schemas/CellsStorage_LTY2Mzc5NzY1"}},"required":["backend","collabora","storage"],"type":"object"},"CellsMetadata_LTY1OTM5MTM0":{"properties":{"namespaces":{"$ref":"#/components/schemas/CellsNamespaces_MzUxMjEzOTQw"}},"required":["namespaces"],"type":"object"},"CellsNamespaces_MzUxMjEzOTQw":{"properties":{"usermetaTags":{"$ref":"#/components/schemas/CellsUserMetaTags_LTc4Njk4NTY0"}},"required":["usermetaTags"],"type":"object"},"CellsPropertyStatus_MTQ5NjE2MzQ4":{"enum":["enabled","disabled","enforced"],"type":"string"},"CellsProperty_NzcxMDIzMzk0":{"properties":{"default":{"$ref":"#/components/schemas/CellsPropertyStatus_MTQ5NjE2MzQ4"},"enabled":{"type":"boolean"}},"required":["enabled","default"],"type":"object"},"CellsPublicLinks_MjgxMzQ3Mzk4":{"properties":{"enableFiles":{"type":"boolean"},"enableFolders":{"type":"boolean"},"enforceExpirationDefault":{"format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"enforceExpirationMax":{"format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"enforcePassword":{"type":"boolean"}},"required":["enableFiles","enableFolders","enforcePassword","enforceExpirationMax","enforceExpirationDefault"],"type":"object"},"CellsRecycle_LTQxMTg3NTkx":{"properties":{"allowSkip":{"type":"boolean"},"autoPurgeDays":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"disable":{"type":"boolean"}},"required":["autoPurgeDays","disable","allowSkip"],"type":"object"},"CellsState_LTg4MDEwNDA5":{"enum":["disabled","pending","ready"],"type":"string"},"CellsStorage_LTY2Mzc5NzY1":{"properties":{"perUserQuotaBytes":{"type":"string"}},"required":["perUserQuotaBytes"],"type":"object"},"CellsUserMetaTags_LTc4Njk4NTY0":{"properties":{"allowFreeValues":{"type":"boolean"},"defaultValues":{"items":{"type":"string"},"type":"array"}},"required":["defaultValues","allowFreeValues"],"type":"object"},"CellsUsers_LTQ4NTEyODA1":{"properties":{"externals":{"type":"boolean"},"guests":{"type":"boolean"}},"required":["externals","guests"],"type":"object"},"ChallengeToken_Mzk3NTcwOTM3":{"properties":{"challenge_token":{"$ref":"#/components/schemas/Token"}},"required":["challenge_token"],"type":"object"},"ChannelPermissions_Mzc1MTM3NTg2":{"enum":["team-members","everyone","admins"],"type":"string"},"ChannelsConfigB_Covered_Identity_ODk2MTk3NDQ4":{"properties":{"allowed_to_create_channels":{"$ref":"#/components/schemas/ChannelPermissions_Mzc1MTM3NTg2"},"allowed_to_open_channels":{"$ref":"#/components/schemas/ChannelPermissions_Mzc1MTM3NTg2"}},"required":["allowed_to_create_channels","allowed_to_open_channels"],"type":"object"},"CheckHandles_LTc0OTkxMzAx":{"properties":{"handles":{"items":{"type":"string"},"maxItems":50,"minItems":1,"type":"array"},"return":{"maximum":10,"minimum":1,"type":"integer"}},"required":["handles","return"],"type":"object"},"CheckUserGroupName_LTg0ODU1OTk1":{"properties":{"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["name"],"type":"object"},"CipherSuiteTag":{"description":"The cipher suite of the corresponding MLS group","maximum":65535,"minimum":0,"type":"integer"},"ClassifiedDomainsConfig_LTg4MDcwMDg2":{"properties":{"domains":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["domains"],"type":"object"},"ClientCapabilityList":{"items":{"$ref":"#/components/schemas/ClientCapability_MTY2NDAzMjM3"},"type":"array"},"ClientCapability_MTY2NDAzMjM3":{"enum":["legalhold-implicit-consent","consumable-notifications"],"type":"string"},"ClientClass_NjE3MDgwNzcx":{"enum":["phone","tablet","desktop","legalhold"],"type":"string"},"ClientIdentity_MjAxMjI3NTUw":{"properties":{"client_id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"domain":{"$ref":"#/components/schemas/Domain"},"user_id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","user_id","client_id"],"type":"object"},"ClientMismatch_ODUyODM0MDQ0":{"properties":{"deleted":{"$ref":"#/components/schemas/UserClients"},"missing":{"$ref":"#/components/schemas/UserClients"},"redundant":{"$ref":"#/components/schemas/UserClients"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["time","missing","redundant","deleted"],"type":"object"},"ClientPrekey_LTcyODUzMTcw":{"properties":{"client":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"prekey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"}},"required":["client","prekey"],"type":"object"},"ClientType_MjQ0OTQwMzcw":{"enum":["temporary","permanent","legalhold"],"type":"string"},"Client_MTM1OTcwOTQ1":{"properties":{"capabilities":{"$ref":"#/components/schemas/ClientCapabilityList"},"class":{"$ref":"#/components/schemas/ClientClass_NjE3MDgwNzcx"},"cookie":{"type":"string"},"id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"label":{"type":"string"},"last_active":{"$ref":"#/components/schemas/UTCTime"},"mls_public_keys":{"$ref":"#/components/schemas/MLSPublicKeys"},"model":{"type":"string"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"},"type":{"$ref":"#/components/schemas/ClientType_MjQ0OTQwMzcw"}},"required":["id","type","time"],"type":"object"},"CodeChallengeMethod_NTIxNzk0NDgw":{"description":"The method used to encode the code challenge. Only `S256` is supported.","enum":["S256"],"type":"string"},"CollaboraEdition_LTg2NDA1NDQ4":{"enum":["NO","CODE","COOL"],"type":"string"},"CollaboratorPermission_NDg5NTg2ODgy":{"description":"

Permission granted to a team collaborator.

  • `create_team_conversation`: equivalent to the `CreateConversation` and `AddRemoveConvMember` permissions for team members (both implied in the `member` role); allows creating team group conversations and adding members to them.
  • \n
  • `implicit_connection`: team members are implicitly connected to each other, allowing conversations (1:1 or group) without an explicit connection request. This permission grants the same to a collaborator.
\n

NB: a member of team A can always open conversations with a collaborator of team A; the permission only controls the collaborator's abilities.

","enum":["create_team_conversation","implicit_connection"],"type":"string"},"CommitBundle":{"description":"This object can only be parsed in TLS format. Please refer to the MLS specification for details."},"CompletePasswordReset_LTYzMDAxNDA1":{"properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"},"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["key","code","password"],"type":"object"},"CompletePasswordReset_NDcyMjY5OTc4":{"description":"Data to complete a password reset","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"email":{"$ref":"#/components/schemas/Email"},"key":{"$ref":"#/components/schemas/ASCII"},"password":{"description":"New password (6 - 1024 characters)","maxLength":1024,"minLength":8,"type":"string"},"phone":{"$ref":"#/components/schemas/PhoneNumber"}},"required":["code","password"],"type":"object"},"ConferenceCallingConfigB_Covered_Identity_NDMwMjcyMzM1":{"properties":{"useSFTForOneToOneCalls":{"type":"boolean"}},"type":"object"},"Connect_ODY3OTE4NTYx":{"properties":{"email":{"type":"string"},"message":{"type":"string"},"name":{"type":"string"},"qualified_recipient":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"recipient":{"$ref":"#/components/schemas/UUID"}},"required":["qualified_recipient"],"type":"object"},"ConnectionUpdate_LTU3MTA1OTA5":{"properties":{"status":{"$ref":"#/components/schemas/Relation_LTE4OTU5MTk4"}},"required":["status"],"type":"object"},"Connections_PagingState":{"type":"string"},"Contact_LTcwODE3Mjc5":{"description":"Contact discovered through search","properties":{"accent_id":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"handle":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"}},"required":["qualified_id","name","type"],"type":"object"},"ConvMembers_LTc2MDg1NDg2":{"description":"Users of a conversation","properties":{"others":{"description":"All other current users of this conversation","items":{"$ref":"#/components/schemas/OtherMember_LTgzNzE2MTk4"},"type":"array"},"self":{"$ref":"#/components/schemas/Member_OTA5OTgyNzcw"}},"required":["others"],"type":"object"},"ConvTeamInfo_Mzc5NjcyNjAz":{"description":"Team information of this conversation","properties":{"managed":{"description":"This field MUST NOT be used by clients. It is here only for backwards compatibility of the interface."},"teamid":{"$ref":"#/components/schemas/UUID"}},"required":["teamid","managed"],"type":"object"},"ConvType_MzM0NTE3ODE5":{"enum":[0,1,2,3],"type":"integer"},"ConversationAccessData_MjMxMTI5ODc3":{"properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"}},"required":["access","access_role"],"type":"object"},"ConversationCodeInfo_LTc5MzgzNjg3":{"description":"Contains conversation properties to update","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"has_password":{"description":"Whether the conversation has a password","type":"boolean"},"key":{"$ref":"#/components/schemas/ASCII"},"uri":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["key","code","uri","has_password"],"type":"object"},"ConversationCode_Mjg3OTI1NTMx":{"description":"Contains conversation properties to update","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"}},"required":["key","code"],"type":"object"},"ConversationCoverView_LTMwNDkxMTA1":{"description":"Limited view of Conversation.","properties":{"has_password":{"type":"boolean"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"}},"required":["id","has_password"],"type":"object"},"ConversationHistoryUpdate_LTg5MDQ5Nzgx":{"properties":{"history":{"$ref":"#/components/schemas/History"}},"required":["history"],"type":"object"},"ConversationIds_PagingState":{"type":"string"},"ConversationMessageTimerUpdate_LTcxMjUwNzQ4":{"description":"Contains conversation properties to update","properties":{"message_timer":{"format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"type":"object"},"ConversationPage_LTIwMDU2NDI3":{"description":"This is the last page if it contains fewer rows than requested. There may be 0 rows on a page.","properties":{"page":{"items":{"$ref":"#/components/schemas/ConversationSearchResult_NDI0MTcyMDU3"},"type":"array"}},"required":["page"],"type":"object"},"ConversationReceiptModeUpdate_NDE4MzUzNTU3":{"description":"Contains conversation receipt mode to update to. Receipt mode tells clients whether certain types of receipts should be sent in the given conversation or not. How this value is interpreted is up to clients.","properties":{"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["receipt_mode"],"type":"object"},"ConversationRename_ODkwODg1MzQ0":{"properties":{"name":{"description":"The new conversation name","type":"string"}},"required":["name"],"type":"object"},"ConversationReset_MzU1Nzc5MjAw":{"properties":{"group_id":{"$ref":"#/components/schemas/GroupId"},"new_group_id":{"$ref":"#/components/schemas/GroupId"}},"required":["group_id"],"type":"object"},"ConversationRole":{"properties":{"actions":{"description":"The set of actions allowed for this role","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"conversation_role":{"$ref":"#/components/schemas/RoleName"}}},"ConversationRolesList":{"properties":{"conversation_roles":{"items":{"$ref":"#/components/schemas/ConversationRole"},"type":"array"}},"required":["conversation_roles"],"type":"object"},"ConversationSearchResult_NDI0MTcyMDU3":{"properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"admin_count":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"id":{"$ref":"#/components/schemas/UUID"},"member_count":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"}},"required":["id","access","member_count","admin_count"],"type":"object"},"Conversation_LTU5NTc0NTI2":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/ConvMembers_LTc2MDg1NDg2"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"},"ConversationsResponse_NzgwNjAxNjQz":{"description":"Response object for getting metadata of a list of conversations","properties":{"failed":{"description":"The server failed to fetch these conversations, most likely due to network issues while contacting a remote server","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"},"found":{"items":{"$ref":"#/components/schemas/OwnConversation_NDU4NDc3MDgz"},"type":"array"},"not_found":{"description":"These conversations either don't exist or are deleted.","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"}},"required":["found","not_found","failed"],"type":"object"},"CookieList_LTM4MzYwNzAz":{"description":"List of cookie information","properties":{"cookies":{"items":{"$ref":"#/components/schemas/Cookie_LTkyMDA3OTI5"},"type":"array"}},"required":["cookies"],"type":"object"},"CookieType_LTE0MjczNzY3":{"enum":["session","persistent"],"type":"string"},"Cookie_LTkyMDA3OTI5":{"properties":{"created":{"$ref":"#/components/schemas/UTCTime"},"expires":{"$ref":"#/components/schemas/UTCTime"},"id":{"format":"int32","maximum":4294967295,"minimum":0,"type":"integer"},"label":{"type":"string"},"successor":{"format":"int32","maximum":4294967295,"minimum":0,"type":"integer"},"type":{"$ref":"#/components/schemas/CookieType_LTE0MjczNzY3"}},"required":["id","type","created","expires"],"type":"object"},"CreateConversationCodeRequest_NTYzMTA1NDYz":{"description":"Request body for creating a conversation code","properties":{"password":{"description":"Password for accessing the conversation via guest link. Set to null or omit for no password.","maxLength":1024,"minLength":8,"type":"string"}},"type":"object"},"CreateGroupConversation_LTE2NzQxMDI0":{"description":"A created group-conversation object extended with a list of failed-to-add users","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"failed_to_add":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/ConvMembers_LTc2MDg1NDg2"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch","failed_to_add"],"type":"object"},"CreateOAuthAuthorizationCodeRequest_LTQ3NzI1Mjkz":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"code_challenge":{"$ref":"#/components/schemas/OAuthCodeChallenge"},"code_challenge_method":{"$ref":"#/components/schemas/CodeChallengeMethod_NTIxNzk0NDgw"},"redirect_uri":{"$ref":"#/components/schemas/RedirectUrl"},"response_type":{"$ref":"#/components/schemas/OAuthResponseType_ODI2Mjg3NzQx"},"scope":{"description":"The scopes which are requested to get authorization for, separated by a space","type":"string"},"state":{"description":"An opaque value used by the client to maintain state between the request and callback. The authorization server includes this value when redirecting the user-agent back to the client. The parameter SHOULD be used for preventing cross-site request forgery","type":"string"}},"required":["client_id","scope","response_type","redirect_uri","state","code_challenge_method","code_challenge"],"type":"object"},"CreateScimTokenResponse_LTIzOTU2NDU4":{"properties":{"info":{"$ref":"#/components/schemas/ScimTokenInfo_LTI5NjgwNzA1"},"token":{"type":"string"}},"required":["token","info"],"type":"object"},"CreateScimToken_OTY0NjYxMDQ2":{"properties":{"description":{"type":"string"},"idp":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"required":["description"],"type":"object"},"CreateUserTeam_MzI4NDQ1Mzkw":{"properties":{"team_id":{"$ref":"#/components/schemas/UUID"},"team_name":{"type":"string"}},"required":["team_id","team_name"],"type":"object"},"CreatedApp_LTM3NjUxOTY1":{"properties":{"cookie":{"$ref":"#/components/schemas/SomeUserToken"},"user":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}},"required":["user","cookie"],"type":"object"},"CustomBackend_LTQxODI0MjQ0":{"description":"Description of a custom backend","properties":{"config_json_url":{"$ref":"#/components/schemas/HttpsUrl"},"webapp_welcome_url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["config_json_url","webapp_welcome_url"],"type":"object"},"DPoPAccessToken":{"type":"string"},"DPoPAccessTokenResponse_LTgyODU5MDE3":{"properties":{"expires_in":{"format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"token":{"$ref":"#/components/schemas/DPoPAccessToken"},"type":{"$ref":"#/components/schemas/AccessTokenType_LTgyOTY0NDE5"}},"required":["token","type","expires_in"],"type":"object"},"DeleteKeyPackages_LTQxNTcxNjY3":{"properties":{"key_packages":{"items":{"$ref":"#/components/schemas/KeyPackageRef"},"maxItems":1000,"minItems":1,"type":"array"}},"required":["key_packages"],"type":"object"},"DeleteProvider_MzYxMzM3Mjg2":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["password"],"type":"object"},"DeleteService_LTY2NzY5NzMz":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["password"],"type":"object"},"DeleteUser_NjE0MjE2Mjkz":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"DeletionCodeTimeout_LTU1MTk0NDI3":{"properties":{"expires_in":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["expires_in"],"type":"object"},"DisableLegalHoldForUserRequest_LTYyMDYxOTEy":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"Domain":{"example":"example.com","type":"string"},"DomainOwnershipToken_NTU0ODc1NDE5":{"properties":{"domain_ownership_token":{"$ref":"#/components/schemas/Token"}},"required":["domain_ownership_token"],"type":"object"},"DomainRedirectConfigTag_MjE2MDI4MDIw":{"enum":["remove","backend","no-registration"],"type":"string"},"DomainRedirectConfig_NTI5NDE5MDQy":{"properties":{"backend":{"$ref":"#/components/schemas/HttpsUrl_HttpsUrl_NjUyMDgzNzk3"},"domain_redirect":{"$ref":"#/components/schemas/DomainRedirectConfigTag_MjE2MDI4MDIw"}},"required":["domain_redirect","backend"],"type":"object"},"DomainRedirectResponse_V10_LTEyMjI4NTM0":{"properties":{"backend":{"$ref":"#/components/schemas/HttpsUrl_Maybe_HttpsUrl_LTQ1MDkyMzY2"},"domain_redirect":{"$ref":"#/components/schemas/DomainRedirectTag_LTY3NjU1MDEy"},"due_to_existing_account":{"type":"boolean"},"sso_code":{"$ref":"#/components/schemas/UUID"}},"required":["domain_redirect","sso_code","backend"],"type":"object"},"DomainRedirectTag_LTY3NjU1MDEy":{"enum":["none","locked","sso","backend","no-registration","pre-authorized"],"type":"string"},"DomainRegistrationResponse_V10_MjE0NDkxODY4":{"properties":{"authorized_team":{"$ref":"#/components/schemas/UUID"},"backend":{"$ref":"#/components/schemas/HttpsUrl_Maybe_HttpsUrl_LTQ1MDkyMzY2"},"dns_verification_token":{"$ref":"#/components/schemas/ASCII"},"domain":{"$ref":"#/components/schemas/Domain"},"domain_redirect":{"$ref":"#/components/schemas/DomainRedirectTag_LTY3NjU1MDEy"},"sso_code":{"$ref":"#/components/schemas/UUID"},"team":{"$ref":"#/components/schemas/UUID"},"team_invite":{"$ref":"#/components/schemas/TeamInviteTag_LTQyNTMyNzA0"}},"required":["domain","domain_redirect","sso_code","backend","team_invite","team"],"type":"object"},"DomainVerificationChallenge_NjIwMzA1MjE5":{"properties":{"dns_verification_token":{"$ref":"#/components/schemas/ASCII"},"id":{"$ref":"#/components/schemas/UUID"},"token":{"$ref":"#/components/schemas/Token"}},"required":["id","token","dns_verification_token"],"type":"object"},"EdMemberLeftReason_OTAyMDA4NzEw":{"enum":["left","user-deleted","removed"],"type":"string"},"EdMemberLeftReason_QualifiedUserIdList_LTYyOTg2OTQ1":{"properties":{"qualified_user_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"reason":{"$ref":"#/components/schemas/EdMemberLeftReason_OTAyMDA4NzEw"},"user_ids":{"deprecated":true,"description":"Deprecated, use qualified_user_ids","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["reason","qualified_user_ids","user_ids"],"type":"object"},"Either_OAuthAccessTokenRequest_OAuthRefreshAccessTokenRequest":{"oneOf":[{"properties":{"Left":{"$ref":"#/components/schemas/OAuthAccessTokenRequest_LTYyNTcyMzI4"}},"required":["Left"],"title":"Left","type":"object"},{"properties":{"Right":{"$ref":"#/components/schemas/OAuthRefreshAccessTokenRequest_LTUwNjA3OTI1"}},"required":["Right"],"title":"Right","type":"object"}]},"Email":{"type":"string"},"EmailUpdate_LTYwODE0ODQ5":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"EmailUpdate_NjQ5MDg1OTY0":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"EnforceFilDwadLtCgB_vIy_LTQ2NjU1Nzcx":{"properties":{"enforcedDownloadLocation":{"type":"string"}},"type":"object"},"EpochTimestamp":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"},"EventType_LTQ3NTQyNDYz":{"enum":["conversation.member-join","conversation.member-leave","conversation.member-update","conversation.rename","conversation.access-update","conversation.receipt-mode-update","conversation.message-timer-update","conversation.code-update","conversation.code-delete","conversation.create","conversation.delete","conversation.mls-reset","conversation.connect-request","conversation.typing","conversation.otr-message-add","conversation.mls-message-add","conversation.mls-welcome","conversation.protocol-update","conversation.add-permission-update","conversation.history-update"],"type":"string"},"EventVia_Mjc4MzcyNzE0":{"enum":["scim","user"],"type":"string"},"Event_LTMwMTMyODM5":{"properties":{"conversation":{"$ref":"#/components/schemas/UUID"},"data":{"description":"The action of changing the permission to add members to a channel","example":"ZXhhbXBsZQo=","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"$ref":"#/components/schemas/AccessRoleLegacy_LTYwOTAxMDI1"},"access_role_v2":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"add_type":{"$ref":"#/components/schemas/JoinType_LTY4MDg2MzA5"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"code":{"$ref":"#/components/schemas/ASCII"},"conversation_role":{"$ref":"#/components/schemas/RoleName"},"creator":{"$ref":"#/components/schemas/UUID"},"data":{"description":"Extra (symmetric) data (i.e. ciphertext, Base64 in JSON) that is common with all other recipients.","type":"string"},"depth":{"$ref":"#/components/schemas/HistoryDuration"},"email":{"type":"string"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/EpochTimestamp"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"has_password":{"description":"Whether the conversation has a password","type":"boolean"},"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"key":{"$ref":"#/components/schemas/ASCII"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message":{"type":"string"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"new_group_id":{"$ref":"#/components/schemas/GroupId"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_recipient":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"qualified_target":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"qualified_user_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"reason":{"$ref":"#/components/schemas/EdMemberLeftReason_OTAyMDA4NzEw"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"recipient":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"sender":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"status":{"$ref":"#/components/schemas/TypingStatus_LTg5MzcyNDMy"},"target":{"$ref":"#/components/schemas/UUID"},"team":{"$ref":"#/components/schemas/UUID"},"text":{"description":"The ciphertext for the recipient (Base64 in JSON)","type":"string"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"},"uri":{"$ref":"#/components/schemas/HttpsUrl"},"user_ids":{"deprecated":true,"description":"Deprecated, use qualified_user_ids","items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"users":{"items":{"$ref":"#/components/schemas/SimpleMember_NTY5MTcxMzcx"},"type":"array"}},"required":["users","add_type","reason","qualified_user_ids","user_ids","qualified_target","name","access","key","code","uri","has_password","qualified_id","type","members","group_id","epoch","epoch_timestamp","cipher_suite","qualified_recipient","receipt_mode","sender","recipient","text","status","add_permission","depth"],"type":"object"},"from":{"$ref":"#/components/schemas/UUID"},"qualified_conversation":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_from":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"subconv":{"type":"string"},"team":{"$ref":"#/components/schemas/UUID"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"},"type":{"$ref":"#/components/schemas/EventType_LTQ3NTQyNDYz"},"via":{"$ref":"#/components/schemas/EventVia_Mjc4MzcyNzE0"}},"required":["type","data","qualified_conversation","qualified_from","via","time"],"type":"object"},"Featur_EnfocilDwdLCgBIy_MjcxMzc1NDA5":{"properties":{"config":{"$ref":"#/components/schemas/EnforceFilDwadLtCgB_vIy_LTQ2NjU1Nzcx"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Featur_PvnAdmilsGopCfgBIy_LTE2NzM3ODkx":{"properties":{"config":{"$ref":"#/components/schemas/PreventAdminlessGroupsConfigB_Covered_Identity_MjQ2NDYwMTk2"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Featur_SlfDingMsCoBIdy_LTg2MzYzNjc2":{"properties":{"config":{"$ref":"#/components/schemas/SelfDeletingMessagesConfigB_Covered_Identity_LTY0NzQ4MzU1"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"FeatureStatus_LTMzMTUwODEw":{"enum":["enabled","disabled"],"type":"string"},"Feature_AppLockConfigB_Bare_Identity_MTAzMjI5NDYy":{"properties":{"config":{"$ref":"#/components/schemas/AppLockConfigB_Covered_Identity_NDIxOTc2Njkz"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_BackgroundEffectsConfig_MjQyOTkxMDc4":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_CellsConfigB_Bare_Identity_NzU2NjgxNzEw":{"properties":{"config":{"$ref":"#/components/schemas/CellsConfigB_Covered_Identity_LTE1NzkwOTcz"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_ChannelsConfigB_Bare_Identity_LTU4NTE4MTgx":{"properties":{"config":{"$ref":"#/components/schemas/ChannelsConfigB_Covered_Identity_ODk2MTk3NDQ4"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_ConferenceCallingConfigB_Bare_Identity_Njc2NTcxNTI3":{"properties":{"config":{"$ref":"#/components/schemas/ConferenceCallingConfigB_Covered_Identity_NDMwMjcyMzM1"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_ExposeInvitationURLsToTeamAdminConfig_LTY5NzY2Mzg5":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_FileSharingConfig_LTUyNjkxMzM4":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_GuestLinksConfig_NjQyMDMxNjg3":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_LegalholdConfig_NjM3MTkxNjYw":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_MLSConfigB_Bare_Identity_LTI5MjA3MDYy":{"properties":{"config":{"$ref":"#/components/schemas/MLSConfigB_Covered_Identity_LTEzNTk3MzM5"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_MeetingsConfig_NDc2MzM0MDE1":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_MeetingsPremiumConfig_NzE4NjUzMDE0":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_MlsE2EIdConfigB_Bare_Identity_LTUxODYzODEx":{"properties":{"config":{"$ref":"#/components/schemas/MlsE2EIdConfigB_Covered_Identity_NDQ0NjEwNzA3"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_MlsMigrationConfigB_Bare_Identity_LTQyMDAxMTkz":{"properties":{"config":{"$ref":"#/components/schemas/MlsMigrationConfigB_Covered_Identity_LTM3NDQ4Mzg4"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_OutlookCalIntegrationConfig_LTg0MjIxNTMx":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_SearchVisibilityAvailableConfig_LTMzNTkxODI1":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_SearchVisibilityInboundConfig_MTI1NzQxODY2":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_SndFactorPasswordChallengeConfig_NDc0MzUyMzQz":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"FederatedUserSearchPolicy_MzkwODA4MTM3":{"description":"Search policy that was applied when searching for users","enum":["no_search","exact_handle_search","full_search"],"type":"string"},"Fingerprint":{"example":"ioy3GeIjgQRsobf2EKGO3O8mq/FofFxHRqy0T4ERIZ8=","type":"string"},"FormRedirect":{"properties":{"uri":{"type":"string"},"xml":{"$ref":"#/components/schemas/AuthnRequest"}},"type":"object"},"Frequency_Mzk0ODQwOTM3":{"enum":["daily","weekly","monthly","yearly"],"type":"string"},"GetByEmailReq_LTY4MzE3Njgy":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"GetByEmailResp_LTMxNTY3MjA0":{"properties":{"sso_code":{"$ref":"#/components/schemas/UUID"}},"type":"object"},"GetDomainRegistrationRequest_LTg4NTM1MzM2":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"GetMuliTabPgRqs_ConcLOrm501_LTIwMjA4NzYw":{"description":"A request to list some or all of a user's Connections, including remote ones","properties":{"paging_state":{"$ref":"#/components/schemas/Connections_PagingState"},"size":{"description":"optional, must be <= 500, defaults to 100.","format":"int32","maximum":500,"minimum":1,"type":"integer"}},"type":"object"},"GetMuliTabPgRqs_ConvrIdLcOm10_ODU0NjQ1NDMz":{"description":"A request to list some or all of a user's ConversationIds, including remote ones","properties":{"paging_state":{"$ref":"#/components/schemas/ConversationIds_PagingState"},"size":{"description":"optional, must be <= 1000, defaults to 1000.","format":"int32","maximum":1000,"minimum":1,"type":"integer"}},"type":"object"},"GroupConvType_LTU4NjU0MTY5":{"enum":["group_conversation","channel","meeting"],"type":"string"},"GroupId":{"example":"ZXhhbXBsZQo=","type":"string"},"GroupInfoData":{"description":"This object can only be parsed in TLS format. Please refer to the MLS specification for details."},"Handle":{"type":"string"},"HandleUpdate_NTI4NDk1OTAx":{"properties":{"handle":{"type":"string"}},"required":["handle"],"type":"object"},"History":{"properties":{"depth":{"$ref":"#/components/schemas/HistoryDuration"}},"required":["depth"],"type":"object"},"HistoryDuration":{"type":"string"},"HistorySharingConfig_Mjc4MzA1Nzgw":{"properties":{"depth":{"$ref":"#/components/schemas/HistoryDuration"}},"required":["depth"],"type":"object"},"HttpsUrl":{"example":"https://example.com","type":"string"},"HttpsUrl_HttpsUrl_NjUyMDgzNzk3":{"properties":{"config_url":{"$ref":"#/components/schemas/HttpsUrl"},"webapp_url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["config_url","webapp_url"],"type":"object"},"HttpsUrl_Maybe_HttpsUrl_LTQ1MDkyMzY2":{"properties":{"config_url":{"$ref":"#/components/schemas/HttpsUrl"},"webapp_url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["config_url"],"type":"object"},"Icon":{"description":"S3 asset key for an icon image with retention information. Allows special value 'default'.","example":"3-1-47de4580-ae51-4650-acbb-d10c028cb0ac","type":"string"},"IdObject_ClientId_LTM3NjQyODM5":{"properties":{"id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"}},"required":["id"],"type":"object"},"IdPConfig_WireIdP_NDA5MTE4Mjk0":{"properties":{"extraInfo":{"$ref":"#/components/schemas/WireIdP_ODMzOTExMzYw"},"id":{"$ref":"#/components/schemas/URI"},"metadata":{"$ref":"#/components/schemas/IdPMetadata_MTI3NzE4MTA0"}},"required":["id","metadata","extraInfo"],"type":"object"},"IdPList":{"properties":{"providers":{"items":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"},"type":"array"}},"required":["providers"],"type":"object"},"IdPMetadataInfo":{"maxProperties":1,"minProperties":1,"properties":{"value":{"type":"string"}},"type":"object"},"IdPMetadata_MTI3NzE4MTA0":{"properties":{"certAuthnResponse":{"items":{"$ref":"#/components/schemas/SignedCertificate"},"minItems":1,"type":"array"},"issuer":{"$ref":"#/components/schemas/URI"},"requestURI":{"type":"string"}},"required":["issuer","requestURI","certAuthnResponse"],"type":"object"},"Id_AuthnRequest":{"properties":{"iD":{"type":"string"}},"required":["iD"],"type":"object"},"InvitationList_ODk4NTQxODc3":{"description":"A list of sent team invitations.","properties":{"has_more":{"description":"Indicator that the server has more invitations than returned.","type":"boolean"},"invitations":{"items":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"},"type":"array"}},"required":["invitations","has_more"],"type":"object"},"InvitationRequest_LTcyMDIzNDc0":{"description":"A request to join a team on Wire.","properties":{"allow_existing":{"description":"Whether invitations to existing users are allowed.","type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"locale":{"$ref":"#/components/schemas/Locale"},"name":{"description":"Name of the invitee (1 - 128 characters).","maxLength":128,"minLength":1,"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"}},"required":["email"],"type":"object"},"InvitationUserView_LTUyMTE3Nzkz":{"properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"created_by_email":{"$ref":"#/components/schemas/Email"},"email":{"$ref":"#/components/schemas/Email"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"description":"Name of the invitee (1 - 128 characters)","maxLength":128,"minLength":1,"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"},"team":{"$ref":"#/components/schemas/UUID"},"url":{"$ref":"#/components/schemas/URIRef_Absolute"}},"required":["team","id","created_at","email"],"type":"object"},"Invitation_NTkzMDYwODc1":{"description":"An invitation to join a team on Wire. If invitee is invited from an existing personal account, inviter email is included.","properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"email":{"$ref":"#/components/schemas/Email"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"description":"Name of the invitee (1 - 128 characters)","maxLength":128,"minLength":1,"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"},"team":{"$ref":"#/components/schemas/UUID"},"url":{"$ref":"#/components/schemas/URIRef_Absolute"}},"required":["team","id","created_at","email"],"type":"object"},"InviteQualified_ODYyODIyNjYz":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"qualified_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"minItems":1,"type":"array"}},"required":["qualified_users"],"type":"object"},"JoinConversationByCode_NjgzMzM4Mjg5":{"description":"Request body for joining a conversation by code","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"},"password":{"maxLength":1024,"minLength":8,"type":"string"}},"required":["key","code"],"type":"object"},"JoinType_LTY4MDg2MzA5":{"enum":["external_add","internal_add"],"type":"string"},"KeyMap_Value_MzAxODEwOTgx":{"type":"object"},"KeyPackage":{"example":"a2V5IHBhY2thZ2UgZGF0YQo=","type":"string"},"KeyPackageBundleEntry_NDQ2MzQ2MzMz":{"properties":{"client":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"domain":{"$ref":"#/components/schemas/Domain"},"key_package":{"$ref":"#/components/schemas/KeyPackage"},"key_package_ref":{"$ref":"#/components/schemas/KeyPackageRef"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["domain","user","client","key_package_ref","key_package"],"type":"object"},"KeyPackageBundle_MjU2MjY0MDU2":{"properties":{"key_packages":{"items":{"$ref":"#/components/schemas/KeyPackageBundleEntry_NDQ2MzQ2MzMz"},"type":"array"}},"required":["key_packages"],"type":"object"},"KeyPackageCount_LTYwNDg5MDcz":{"properties":{"count":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["count"],"type":"object"},"KeyPackageRef":{"example":"ZXhhbXBsZQo=","type":"string"},"KeyPackageUpload_NTQ2Mjk2NzEx":{"properties":{"key_packages":{"items":{"$ref":"#/components/schemas/KeyPackage"},"type":"array"}},"required":["key_packages"],"type":"object"},"LHServiceStatus_ODc3NzE0Mjg3":{"enum":["configured","not_configured","disabled"],"type":"string"},"LimitedQualifiedUserIdList_500":{"properties":{"qualified_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["qualified_users"],"type":"object"},"ListConversations_MjkxMTIwODMz":{"description":"A request to list some of a user's conversations, including remote ones. Maximum 1000 qualified conversation IDs","properties":{"qualified_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"maxItems":1000,"minItems":1,"type":"array"}},"required":["qualified_ids"],"type":"object"},"ListType_LTkyMDM4MzA1":{"description":"true if 'members' doesn't contain all team members","enum":[true,false],"type":"boolean"},"ListUsersById_LTQ5MTE3NDc0":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"minItems":1,"type":"array"},"found":{"items":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"},"type":"array"}},"required":["found"],"type":"object"},"ListUsersQuery":{"description":"exactly one of qualified_ids or qualified_handles must be provided.","example":{"qualified_ids":[{"domain":"example.com","id":"00000000-0000-0000-0000-000000000000"}]},"properties":{"qualified_handles":{"items":{"$ref":"#/components/schemas/Qualified_Handle_Nzg0MDE3Nzk4"},"type":"array"},"qualified_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"type":"object"},"Locale":{"type":"string"},"LocaleUpdate_LTgzNjgyOTEw":{"properties":{"locale":{"$ref":"#/components/schemas/Locale"}},"required":["locale"],"type":"object"},"LockStatus_LTIyMTU5OTkw":{"enum":["locked","unlocked"],"type":"string"},"LockableFeature_AllowedGlobalOperationsConfig_NjQ1MjA5MDYw":{"properties":{"config":{"$ref":"#/components/schemas/AllowedGlobalOperationsConfig_MzAwOTU1MDkx"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5":{"properties":{"config":{"$ref":"#/components/schemas/AppLockConfigB_Covered_Identity_NDIxOTc2Njkz"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_AppsConfig_MzQyNTMxNTk5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_AssetAuditLogConfig_NDQ3MzcyMzk2":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_BackgroundEffectsConfig_MTg1MTk3NTM5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3":{"properties":{"config":{"$ref":"#/components/schemas/CellsConfigB_Covered_Identity_LTE1NzkwOTcz"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw":{"properties":{"config":{"$ref":"#/components/schemas/ChannelsConfigB_Covered_Identity_ODk2MTk3NDQ4"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_ChatBubblesConfig_NDgwMTQzNjI2":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_ClassifiedDomainsConfig_LTY1ODQwODg1":{"properties":{"config":{"$ref":"#/components/schemas/ClassifiedDomainsConfig_LTg4MDcwMDg2"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_ConsumableNotificationsConfig_MjUxMjczMjM0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_DigitalSignaturesConfig_ODM2MDA2NDU4":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_DomainRegistrationConfig_NzU0NjczNTE0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_FileSharingConfig_MjgwNjIzODEz":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_GuestLinksConfig_LTcwNjU0NDMw":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_LegalholdConfig_LTc5MTk5OTIw":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_LimitedEventFanoutConfig_MTg3ODM0NzU0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw":{"properties":{"config":{"$ref":"#/components/schemas/MLSConfigB_Covered_Identity_LTEzNTk3MzM5"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_MeetingsPremiumConfig_NDg1ODEyNTA1":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4":{"properties":{"config":{"$ref":"#/components/schemas/MlsE2EIdConfigB_Covered_Identity_NDQ0NjEwNzA3"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_SSOConfig_NjcyMjU4MDY2":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_StealthUsersConfig_LTE1MTk2NzIz":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFtur_CnfigBIdy_NzY1NDU5MDAy":{"properties":{"config":{"$ref":"#/components/schemas/ConferenceCallingConfigB_Covered_Identity_NDMwMjcyMzM1"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFtur_CsInfigBdy_LTQ3MTU3Mjg0":{"properties":{"config":{"$ref":"#/components/schemas/CellsInternalConfigB_Covered_Identity_LTUzMDkwOTAz"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4":{"properties":{"config":{"$ref":"#/components/schemas/EnforceFilDwadLtCgB_vIy_LTQ2NjU1Nzcx"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFtur_MsignCfBIdy_LTE1NjAxNjU2":{"properties":{"config":{"$ref":"#/components/schemas/MlsMigrationConfigB_Covered_Identity_LTM3NDQ4Mzg4"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw":{"properties":{"config":{"$ref":"#/components/schemas/PreventAdminlessGroupsConfigB_Covered_Identity_MjQ2NDYwMTk2"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFtur_RqiExnmVfCg_NjUyMzgzNzY5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2":{"properties":{"config":{"$ref":"#/components/schemas/SelfDeletingMessagesConfigB_Covered_Identity_LTY0NzQ4MzU1"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFtur_SimpfdUsCnRqQg_Njk5NjU4OTgy":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Login_LTgyNTIzMTM1":{"properties":{"email":{"$ref":"#/components/schemas/Email"},"handle":{"$ref":"#/components/schemas/Handle"},"label":{"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"required":["password"],"type":"object"},"MLSConfigB_Covered_Identity_LTEzNTk3MzM5":{"description":"allowlist of users that may change protocols","properties":{"allowedCipherSuites":{"items":{"$ref":"#/components/schemas/CipherSuiteTag"},"type":"array"},"defaultCipherSuite":{"$ref":"#/components/schemas/CipherSuiteTag"},"defaultProtocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"groupInfoDiagnostics":{"type":"boolean"},"protocolToggleUsers":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"supportedProtocols":{"items":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"type":"array"}},"required":["protocolToggleUsers","defaultProtocol","allowedCipherSuites","defaultCipherSuite","supportedProtocols"],"type":"object"},"MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx":{"properties":{"removal":{"$ref":"#/components/schemas/MLSKeys_SomeKey_LTUzNDA5MzA3"}},"required":["removal"],"type":"object"},"MLSKeys_SomeKey_LTUzNDA5MzA3":{"properties":{"ecdsa_secp256r1_sha256":{"$ref":"#/components/schemas/SomeKey"},"ecdsa_secp384r1_sha384":{"$ref":"#/components/schemas/SomeKey"},"ecdsa_secp521r1_sha512":{"$ref":"#/components/schemas/SomeKey"},"ed25519":{"$ref":"#/components/schemas/SomeKey"}},"required":["ed25519","ecdsa_secp256r1_sha256","ecdsa_secp384r1_sha384","ecdsa_secp521r1_sha512"],"type":"object"},"MLSMessage":{"description":"This object can only be parsed in TLS format. Please refer to the MLS specification for details."},"MLSMessageSendingStatus_NjA1NDA0MTE4":{"properties":{"events":{"description":"A list of events caused by sending the message.","items":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"},"type":"array"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["events","time"],"type":"object"},"MLSOne2OneConversation_SomeKey_LTMxNjE2NjUy":{"properties":{"conversation":{"$ref":"#/components/schemas/v9_OwnConversation_NDU4NDc3MDgz"},"public_keys":{"$ref":"#/components/schemas/MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx"}},"required":["conversation","public_keys"],"type":"object"},"MLSPublicKeys":{"additionalProperties":{"example":"ZXhhbXBsZQo=","type":"string"},"description":"Mapping from signature scheme (tags) to public key data","example":{"ecdsa_secp256r1_sha256":"ZXhhbXBsZQo=","ecdsa_secp384r1_sha384":"ZXhhbXBsZQo=","ecdsa_secp521r1_sha512":"ZXhhbXBsZQo=","ed25519":"ZXhhbXBsZQo="},"type":"object"},"MLSReset_NzgwODA3ODc4":{"properties":{"epoch":{"format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"group_id":{"$ref":"#/components/schemas/GroupId"}},"required":["group_id","epoch"],"type":"object"},"MTYxOTI3NjM3":{"enum":["image"],"type":"string"},"ManagedBy_NTI0ODc0NTQx":{"enum":["wire","scim"],"type":"string"},"MeetingEmailsInvitation_NzgyNzUzMzcz":{"description":"Emails invitation","properties":{"emails":{"items":{"$ref":"#/components/schemas/Email"},"type":"array"}},"required":["emails"],"type":"object"},"Meeting_ODU0OTMzMTgw":{"description":"A scheduled meeting","properties":{"created_at":{"$ref":"#/components/schemas/UTCTime"},"end_time":{"$ref":"#/components/schemas/UTCTime"},"invited_emails":{"items":{"$ref":"#/components/schemas/Email"},"type":"array"},"qualified_conversation":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_creator":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Meeting_MjgxMDUyNjc3"},"recurrence":{"$ref":"#/components/schemas/Recurrence_LTQ0OTc0ODE2"},"start_time":{"$ref":"#/components/schemas/UTCTime"},"title":{"maxLength":256,"minLength":1,"type":"string"},"trial":{"type":"boolean"},"updated_at":{"$ref":"#/components/schemas/UTCTime"}},"required":["qualified_id","title","qualified_creator","start_time","end_time","qualified_conversation","invited_emails","trial","created_at","updated_at"],"type":"object"},"MemberUpdateData_LTc3Nzc3NTEy":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"qualified_target":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"target":{"$ref":"#/components/schemas/UUID"}},"required":["qualified_target"],"type":"object"},"MemberUpdate_LTg4NTQ0OTYz":{"properties":{"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"type":"object"},"Member_OTA5OTgyNzcw":{"description":"The user ID of the requestor","properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"status":{},"status_ref":{},"status_time":{}},"required":["qualified_id"],"type":"object"},"MembersJoin_LTg0MDc1NjQ3":{"properties":{"add_type":{"$ref":"#/components/schemas/JoinType_LTY4MDg2MzA5"},"user_ids":{"deprecated":true,"description":"deprecated","items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"users":{"items":{"$ref":"#/components/schemas/SimpleMember_NTY5MTcxMzcx"},"type":"array"}},"required":["users","add_type"],"type":"object"},"MessageSendingStatus_ODg0NDgyNDk4":{"description":"The Proteus message sending status. It has these fields:\n- `time`: Time of sending message.\n- `missing`: Clients that the message /should/ have been encrypted for, but wasn't.\n- `redundant`: Clients that the message /should not/ have been encrypted for, but was.\n- `deleted`: Clients that were deleted.\n- `failed_to_send`: When message sending fails for some clients but succeeds for others, e.g., because a remote backend is unreachable, this field will contain the list of clients for which the message sending failed. This list should be empty when message sending is not even tried, like when some clients are missing.","properties":{"deleted":{"$ref":"#/components/schemas/QualifiedUserClients"},"failed_to_confirm_clients":{"$ref":"#/components/schemas/QualifiedUserClients"},"failed_to_send":{"$ref":"#/components/schemas/QualifiedUserClients"},"missing":{"$ref":"#/components/schemas/QualifiedUserClients"},"redundant":{"$ref":"#/components/schemas/QualifiedUserClients"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["time","missing","redundant","deleted","failed_to_send","failed_to_confirm_clients"],"type":"object"},"MlsE2EIdConfigB_Covered_Identity_NDQ0NjEwNzA3":{"description":"When a client first tries to fetch or renew a certificate, they may need to login to an identity provider (IdP) depending on their IdP domain authentication policy. The user may have a grace period during which they can \"snooze\" this login. The duration of this grace period (in seconds) is set in the `verificationDuration` parameter, which is enforced separately by each client. After the grace period has expired, the client will not allow the user to use the application until they have logged to refresh the certificate. The default value is 1 day (86400s). The client enrolls using the Automatic Certificate Management Environment (ACME) protocol. The `acmeDiscoveryUrl` parameter must be set to the HTTPS URL of the ACME server discovery endpoint for this team. It is of the form \"https://acme.{backendDomain}/acme/{provisionerName}/discovery\". For example: `https://acme.example.com/acme/provisioner1/discovery`.","properties":{"acmeDiscoveryUrl":{"$ref":"#/components/schemas/HttpsUrl"},"crlProxy":{"$ref":"#/components/schemas/HttpsUrl"},"useProxyOnMobile":{"type":"boolean"},"verificationExpiration":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["verificationExpiration"],"type":"object"},"MlsMigrationConfigB_Covered_Identity_LTM3NDQ4Mzg4":{"properties":{"finaliseRegardlessAfter":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"},"startTime":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"}},"type":"object"},"MultiTabePg_ConcsLOrRmU_MTgwMzU4OTU5":{"properties":{"connections":{"items":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"},"type":"array"},"has_more":{"type":"boolean"},"paging_state":{"$ref":"#/components/schemas/Connections_PagingState"}},"required":["connections","has_more","paging_state"],"type":"object"},"MultiTabePg_ConvrsIdqf_cLORm_Q_Mzg0MjYzNzM0":{"properties":{"has_more":{"type":"boolean"},"paging_state":{"$ref":"#/components/schemas/ConversationIds_PagingState"},"qualified_conversations":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"}},"required":["qualified_conversations","has_more","paging_state"],"type":"object"},"NP_LockableFtur_ghdCnfiSOVsyAvIRqExmDpB_MGwUT2Q_LTM1Mzc0ODgy":{"properties":{"allowedGlobalOperations":{"$ref":"#/components/schemas/LockableFeature_AllowedGlobalOperationsConfig_NjQ1MjA5MDYw"},"appLock":{"$ref":"#/components/schemas/LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5"},"apps":{"$ref":"#/components/schemas/LockableFeature_AppsConfig_MzQyNTMxNTk5"},"assetAuditLog":{"$ref":"#/components/schemas/LockableFeature_AssetAuditLogConfig_NDQ3MzcyMzk2"},"backgroundEffects":{"$ref":"#/components/schemas/LockableFeature_BackgroundEffectsConfig_MTg1MTk3NTM5"},"cells":{"$ref":"#/components/schemas/LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3"},"cellsInternal":{"$ref":"#/components/schemas/LockableFtur_CsInfigBdy_LTQ3MTU3Mjg0"},"channels":{"$ref":"#/components/schemas/LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw"},"chatBubbles":{"$ref":"#/components/schemas/LockableFeature_ChatBubblesConfig_NDgwMTQzNjI2"},"classifiedDomains":{"$ref":"#/components/schemas/LockableFeature_ClassifiedDomainsConfig_LTY1ODQwODg1"},"conferenceCalling":{"$ref":"#/components/schemas/LockableFtur_CnfigBIdy_NzY1NDU5MDAy"},"consumableNotifications":{"$ref":"#/components/schemas/LockableFeature_ConsumableNotificationsConfig_MjUxMjczMjM0"},"conversationGuestLinks":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"},"digitalSignatures":{"$ref":"#/components/schemas/LockableFeature_DigitalSignaturesConfig_ODM2MDA2NDU4"},"domainRegistration":{"$ref":"#/components/schemas/LockableFeature_DomainRegistrationConfig_NzU0NjczNTE0"},"enforceFileDownloadLocation":{"$ref":"#/components/schemas/LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4"},"exposeInvitationURLsToTeamAdmin":{"$ref":"#/components/schemas/LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1"},"fileSharing":{"$ref":"#/components/schemas/LockableFeature_FileSharingConfig_MjgwNjIzODEz"},"legalhold":{"$ref":"#/components/schemas/LockableFeature_LegalholdConfig_LTc5MTk5OTIw"},"limitedEventFanout":{"$ref":"#/components/schemas/LockableFeature_LimitedEventFanoutConfig_MTg3ODM0NzU0"},"meetings":{"$ref":"#/components/schemas/LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw"},"meetingsPremium":{"$ref":"#/components/schemas/LockableFeature_MeetingsPremiumConfig_NDg1ODEyNTA1"},"mls":{"$ref":"#/components/schemas/LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw"},"mlsE2EId":{"$ref":"#/components/schemas/LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4"},"mlsMigration":{"$ref":"#/components/schemas/LockableFtur_MsignCfBIdy_LTE1NjAxNjU2"},"outlookCalIntegration":{"$ref":"#/components/schemas/LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0"},"preventAdminlessGroups":{"$ref":"#/components/schemas/LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw"},"searchVisibility":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5"},"searchVisibilityInbound":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy"},"selfDeletingMessages":{"$ref":"#/components/schemas/LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2"},"simplifiedUserConnectionRequestQRCode":{"$ref":"#/components/schemas/LockableFtur_SimpfdUsCnRqQg_Njk5NjU4OTgy"},"sndFactorPasswordChallenge":{"$ref":"#/components/schemas/LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx"},"sso":{"$ref":"#/components/schemas/LockableFeature_SSOConfig_NjcyMjU4MDY2"},"stealthUsers":{"$ref":"#/components/schemas/LockableFeature_StealthUsersConfig_LTE1MTk2NzIz"},"validateSAMLemails":{"$ref":"#/components/schemas/LockableFtur_RqiExnmVfCg_NjUyMzgzNzY5"}},"required":["legalhold","sso","searchVisibility","searchVisibilityInbound","validateSAMLemails","digitalSignatures","appLock","fileSharing","classifiedDomains","conferenceCalling","selfDeletingMessages","conversationGuestLinks","sndFactorPasswordChallenge","mls","exposeInvitationURLsToTeamAdmin","outlookCalIntegration","mlsE2EId","mlsMigration","enforceFileDownloadLocation","limitedEventFanout","domainRegistration","channels","preventAdminlessGroups","cells","allowedGlobalOperations","consumableNotifications","chatBubbles","apps","simplifiedUserConnectionRequestQRCode","assetAuditLog","stealthUsers","cellsInternal","meetings","meetingsPremium","backgroundEffects"],"type":"object"},"NameIDFormat":{"enum":["NameIDFUnspecified","NameIDFEmail","NameIDFX509","NameIDFWindows","NameIDFKerberos","NameIDFEntity","NameIDFPersistent","NameIDFTransient"],"type":"string"},"NameIdPolicy":{"properties":{"allowCreate":{"type":"boolean"},"format":{"$ref":"#/components/schemas/NameIDFormat"},"spNameQualifier":{"type":"string"}},"required":["format","allowCreate"],"type":"object"},"NewApp_LTQwODMwMzQ4":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"category":{"description":"Category name (if uncertain, pick \"other\")","type":"string"},"description":{"maxLength":300,"minLength":0,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["name","category","description","password"],"type":"object"},"NewAssetToken_NTAwMDQwODYy":{"properties":{"token":{"$ref":"#/components/schemas/ASCII"}},"required":["token"],"type":"object"},"NewClient_ODg1NjY4Njgy":{"properties":{"capabilities":{"$ref":"#/components/schemas/ClientCapabilityList"},"class":{"$ref":"#/components/schemas/ClientClass_NjE3MDgwNzcx"},"cookie":{"description":"The cookie label, i.e. the label used when logging in.","type":"string"},"label":{"type":"string"},"lastkey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"mls_public_keys":{"$ref":"#/components/schemas/MLSPublicKeys"},"model":{"type":"string"},"password":{"description":"The password of the authenticated user for verification. Note: Required for registration of the 2nd, 3rd, ... client.","maxLength":1024,"minLength":6,"type":"string"},"prekeys":{"description":"Prekeys for other clients to establish OTR sessions.","items":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"type":"array"},"type":{"$ref":"#/components/schemas/ClientType_MjQ0OTQwMzcw"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"required":["prekeys","lastkey","type"],"type":"object"},"NewConv_LTgzNTk1NDQx":{"description":"JSON object to create a new conversation. When using 'qualified_users' (preferred), you can omit 'users'","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells":{"type":"boolean"},"conversation_role":{"$ref":"#/components/schemas/RoleName"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"history":{"$ref":"#/components/schemas/History"},"message_timer":{"description":"Per-conversation message timer","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"maxLength":256,"minLength":1,"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"qualified_users":{"description":"List of qualified user IDs (excluding the requestor) to be part of this conversation","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"skip_creator":{"description":"Don't add creator to the conversation, only works for team admins not wanting to be part of the channels they create.","type":"boolean"},"team":{"$ref":"#/components/schemas/ConvTeamInfo_Mzc5NjcyNjAz"},"users":{"deprecated":true,"description":"List of user IDs (excluding the requestor) to be part of this conversation (deprecated)","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"type":"object"},"NewLegalHoldService_Mzg0ODQ5NDU1":{"properties":{"auth_token":{"$ref":"#/components/schemas/ASCII"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"public_key":{"$ref":"#/components/schemas/ServiceKeyPEM"}},"required":["base_url","public_key","auth_token"],"type":"object"},"NewMeeting_LTI1NTMzOTU5":{"description":"Request to create a new meeting","properties":{"end_time":{"$ref":"#/components/schemas/UTCTime"},"invited_emails":{"items":{"$ref":"#/components/schemas/Email"},"type":"array"},"recurrence":{"$ref":"#/components/schemas/Recurrence_LTQ0OTc0ODE2"},"start_time":{"$ref":"#/components/schemas/UTCTime"},"title":{"maxLength":256,"minLength":1,"type":"string"}},"required":["start_time","end_time","title"],"type":"object"},"NewOne2OneConv_LTI3OTc4NDAz":{"description":"JSON object to create a new 1:1 conversation. When using 'qualified_users' (preferred), you can omit 'users'","properties":{"name":{"maxLength":256,"minLength":1,"type":"string"},"qualified_users":{"description":"List of qualified user IDs (excluding the requestor) to be part of this conversation","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"team":{"$ref":"#/components/schemas/ConvTeamInfo_Mzc5NjcyNjAz"},"users":{"deprecated":true,"description":"List of user IDs (excluding the requestor) to be part of this conversation (deprecated)","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"type":"object"},"NewOtrMessage_LTUyMTE5MTMw":{"properties":{"data":{"type":"string"},"native_priority":{"$ref":"#/components/schemas/Priority_ODA3NDM3MDYy"},"native_push":{"type":"boolean"},"recipients":{"$ref":"#/components/schemas/UserClientMap"},"report_missing":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"sender":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"transient":{"type":"boolean"}},"required":["sender","recipients"],"type":"object"},"NewPasswordReset_LTEyNzAxMTcy":{"description":"Data to initiate a password reset","properties":{"email":{"$ref":"#/components/schemas/Email"},"phone":{"description":"Email","type":"string"}},"type":"object"},"NewProviderResponse_OTE0ODI2NjU0":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"password":{"maxLength":1024,"minLength":8,"type":"string"}},"required":["id"],"type":"object"},"NewProvider_LTEyMTY5MjYy":{"properties":{"description":{"maxLength":1024,"minLength":1,"type":"string"},"email":{"$ref":"#/components/schemas/Email"},"name":{"maxLength":128,"minLength":1,"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["name","email","url","description"],"type":"object"},"NewServiceResponse_LTExMzcwMjg5":{"properties":{"auth_token":{"$ref":"#/components/schemas/ASCII"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["id"],"type":"object"},"NewService_LTYwOTU1MDQ3":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"auth_token":{"$ref":"#/components/schemas/ASCII"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"description":{"maxLength":1024,"minLength":1,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"public_key":{"$ref":"#/components/schemas/ServiceKeyPEM"},"summary":{"maxLength":128,"minLength":1,"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"maxItems":3,"minItems":1,"type":"array"}},"required":["name","summary","description","base_url","public_key","assets","tags"],"type":"object"},"NewTeamCollaborator_LTIxNjEzMTYw":{"properties":{"permissions":{"items":{"$ref":"#/components/schemas/CollaboratorPermission_NDg5NTg2ODgy"},"type":"array"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","permissions"],"type":"object"},"NewTeamMember_Required_LTg2NjU5OTI2":{"description":"Required data when creating new team members","properties":{"member":{"description":"the team member to add (the legalhold_status field must be null or missing!)","properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"permissions":{"$ref":"#/components/schemas/Permissions_NDE0ODM5NDUx"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","permissions"],"type":"object"}},"required":["member"],"type":"object"},"NewUserGroup_MzYxODU0OTU1":{"properties":{"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["name","members"],"type":"object"},"NewUser_PlainTextPassword_8_LTI4MzI5NzQx":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"email":{"$ref":"#/components/schemas/Email"},"email_code":{"$ref":"#/components/schemas/ASCII"},"expires_in":{"maximum":604800,"minimum":1,"type":"integer"},"invitation_code":{"$ref":"#/components/schemas/ASCII"},"label":{"type":"string"},"locale":{"$ref":"#/components/schemas/Locale"},"managed_by":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"name":{"maxLength":128,"minLength":1,"type":"string"},"password":{"maxLength":1024,"minLength":8,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"sso_id":{"$ref":"#/components/schemas/UserSSOId"},"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"},"team":{"$ref":"#/components/schemas/BindingNewTeamUser_LTY0MDQxMDEw"},"team_code":{"$ref":"#/components/schemas/ASCII"},"team_id":{"$ref":"#/components/schemas/UUID"},"uuid":{"$ref":"#/components/schemas/UUID"}},"required":["name"],"type":"object"},"OAuthAccessTokenRequest_LTYyNTcyMzI4":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"code":{"$ref":"#/components/schemas/OAuthAuthorizationCode"},"code_verifier":{"description":"The code verifier to complete the code challenge","maxLength":128,"minLength":43,"type":"string"},"grant_type":{"$ref":"#/components/schemas/OAuthGrantType_LTIxODA5NDIw"},"redirect_uri":{"$ref":"#/components/schemas/RedirectUrl"}},"required":["grant_type","client_id","code_verifier","code","redirect_uri"],"type":"object"},"OAuthAccessTokenResponse_NzEwOTI4NjQ0":{"properties":{"access_token":{"description":"The access token, which has a relatively short lifetime","type":"string"},"expires_in":{"description":"The lifetime of the access token in seconds","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"refresh_token":{"description":"The refresh token, which has a relatively long lifetime, and can be used to obtain a new access token","type":"string"},"token_type":{"$ref":"#/components/schemas/OAuthAccessTokenType_MjU3ODI0NDIw"}},"required":["access_token","token_type","expires_in","refresh_token"],"type":"object"},"OAuthAccessTokenType_MjU3ODI0NDIw":{"description":"The type of the access token. Currently only `Bearer` is supported.","enum":["Bearer"],"type":"string"},"OAuthApplication_Mjk5NTUxNjA1":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"name":{"description":"The OAuth client's name","maxLength":256,"minLength":6,"type":"string"},"sessions":{"description":"The OAuth client's sessions","items":{"$ref":"#/components/schemas/OAuthSession_LTQxOTIxNTMy"},"type":"array"}},"required":["id","name","sessions"],"type":"object"},"OAuthAuthorizationCode":{"description":"The authorization code","type":"string"},"OAuthClient_NzExMTI5NTIy":{"properties":{"application_name":{"maxLength":256,"minLength":6,"type":"string"},"client_id":{"$ref":"#/components/schemas/UUID"},"redirect_url":{"$ref":"#/components/schemas/RedirectUrl"}},"required":["client_id","application_name","redirect_url"],"type":"object"},"OAuthCodeChallenge":{"description":"Generated by the client from the code verifier (unpadded base64url-encoded SHA256 hash of the code verifier)","type":"string"},"OAuthGrantType_LTIxODA5NDIw":{"description":"Indicates which authorization flow to use. Use `authorization_code` for authorization code flow.","enum":["authorization_code","refresh_token"],"type":"string"},"OAuthRefreshAccessTokenRequest_LTUwNjA3OTI1":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"grant_type":{"$ref":"#/components/schemas/OAuthGrantType_LTIxODA5NDIw"},"refresh_token":{"description":"The refresh token","type":"string"}},"required":["grant_type","client_id","refresh_token"],"type":"object"},"OAuthResponseType_ODI2Mjg3NzQx":{"description":"Indicates which authorization flow to use. Use `code` for authorization code flow.","enum":["code"],"type":"string"},"OAuthRevokeRefreshTokenRequest_MjA1MDg4MzQ4":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"refresh_token":{"description":"The refresh token","type":"string"}},"required":["client_id","refresh_token"],"type":"object"},"OAuthSession_LTQxOTIxNTMy":{"properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"refresh_token_id":{"$ref":"#/components/schemas/UUID"}},"required":["refresh_token_id","created_at"],"type":"object"},"Object":{"additionalProperties":true,"description":"A single notification event","properties":{"type":{"description":"Event type","type":"string"}},"title":"Event","type":"object"},"OtherMemberUpdate_LTM1MjYzOTU0":{"description":"Update user properties of other members relative to a conversation","properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"}},"type":"object"},"OtherMember_LTgzNzE2MTk4":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"id":{"$ref":"#/components/schemas/UUID"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"status":{"deprecated":true,"description":"deprecated","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["qualified_id"],"type":"object"},"OtrMessage_LTY4MTYzNzg3":{"description":"Encrypted message of a conversation","properties":{"data":{"description":"Extra (symmetric) data (i.e. ciphertext, Base64 in JSON) that is common with all other recipients.","type":"string"},"recipient":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"sender":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"text":{"description":"The ciphertext for the recipient (Base64 in JSON)","type":"string"}},"required":["sender","recipient","text"],"type":"object"},"OwnConvMembers_LTEwMzUzODMy":{"description":"Users of a conversation","properties":{"others":{"description":"All other current users of this conversation","items":{"$ref":"#/components/schemas/OtherMember_LTgzNzE2MTk4"},"type":"array"},"self":{"$ref":"#/components/schemas/Member_OTA5OTgyNzcw"}},"required":["self","others"],"type":"object"},"OwnConversation_NDU4NDc3MDgz":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"},"PagingState":{"description":"Paging state that should be supplied to retrieve the next page of results","type":"string"},"PasswordChange_MTgzMDM2NTY2":{"description":"Data to change a password. The old password is required if a password already exists.","properties":{"new_password":{"maxLength":1024,"minLength":8,"type":"string"},"old_password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["new_password"],"type":"object"},"PasswordChange_NDI0ODgwNDU0":{"properties":{"new_password":{"maxLength":1024,"minLength":6,"type":"string"},"old_password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["old_password","new_password"],"type":"object"},"PasswordReqBody_LTcxMzE3ODE3":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"PasswordReset_LTYzNDYxNTQ3":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"Permissions_NDE0ODM5NDUx":{"description":"This is just a complicated way of representing a team role. self and copy always have to contain the same integer, and only the following integers are allowed: 1025 (partner), 1587 (member), 5951 (admin), 8191 (owner). Unit tests of the galley-types package in wire-server contain an authoritative list.","properties":{"copy":{"description":"Permissions that this user is able to grant others","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"self":{"description":"Permissions that the user has","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["self","copy"],"type":"object"},"PhoneNumber":{"description":"A known phone number with a pending password reset.","type":"string"},"Pict_DEPRECATED_USE_ASSETS_INSTEAD":{"items":{"type":"object"},"maxItems":10,"minItems":0,"type":"array"},"PrekeyBundle_MzgzOTk4MjYz":{"properties":{"clients":{"items":{"$ref":"#/components/schemas/ClientPrekey_LTcyODUzMTcw"},"type":"array"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","clients"],"type":"object"},"PreventAdminlessGroupsConfigB_Covered_Identity_MjQ2NDYwMTk2":{"properties":{"deletionTimeout":{"maximum":18446744073709551615,"minimum":0,"type":"integer"},"promotionStrategy":{"$ref":"#/components/schemas/PreventAdminlessGroupsPromotionStrategy_Mjc1ODA2MDU1"},"reminderTimeouts":{"items":{"maximum":18446744073709551615,"minimum":0,"type":"integer"},"type":"array"}},"required":["promotionStrategy","deletionTimeout","reminderTimeouts"],"type":"object"},"PreventAdminlessGroupsPromotionStrategy_Mjc1ODA2MDU1":{"enum":["alphabetical","random","all"],"type":"string"},"Priority_ODA3NDM3MDYy":{"enum":["low","high"],"type":"string"},"PropertyKeysAndValues":{"type":"object"},"PropertyValue":{"description":"An arbitrary JSON value for a property"},"ProtocolTag_ODg1MTE5NjEw":{"enum":["proteus","mls","mixed"],"type":"string"},"ProtocolUpdate_NzY1ODgxNDQy":{"properties":{"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"}},"type":"object"},"ProviderActivationResponse_LTgzNTU3MzA5":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"ProviderLogin_LTE2MTk2NTM5":{"properties":{"email":{"$ref":"#/components/schemas/Email"},"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["email","password"],"type":"object"},"Provider_NDIyMzQ3ODIy":{"properties":{"description":{"type":"string"},"email":{"$ref":"#/components/schemas/Email"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["id","name","email","url","description"],"type":"object"},"PubClient":{"properties":{"class":{"$ref":"#/components/schemas/ClientClass_NjE3MDgwNzcx"},"id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"}},"required":["id"],"type":"object"},"PublicSubConversation_MjI2NTIxMzU4":{"description":"An MLS subconversation","properties":{"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_id":{"$ref":"#/components/schemas/GroupId"},"members":{"items":{"$ref":"#/components/schemas/ClientIdentity_MjAxMjI3NTUw"},"type":"array"},"parent_qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"subconv_id":{"type":"string"}},"required":["parent_qualified_id","subconv_id","group_id","epoch","members"],"type":"object"},"PushTokenList_NDI0Mjc3MzY3":{"description":"List of Native Push Tokens","properties":{"tokens":{"description":"Push tokens","items":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"},"type":"array"}},"required":["tokens"],"type":"object"},"PushToken_ODYzMDYzOTA4":{"description":"Native Push Token","properties":{"app":{"description":"Application","type":"string"},"client":{"description":"Client ID","type":"string"},"token":{"description":"Access Token","type":"string"},"transport":{"$ref":"#/components/schemas/Transport_NDk2NzU5NDIy"}},"required":["transport","app","token","client"],"type":"object"},"PutApp_LTE4MDc1OTM4":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"category":{"description":"Category name (if uncertain, pick \"other\")","type":"string"},"description":{"maxLength":300,"minLength":0,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"}},"type":"object"},"QualifiedNewOtrMessage":{"description":"This object can only be parsed from Protobuf.\nThe specification for the protobuf types is here: \nhttps://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto."},"QualifiedUserClientPrekeyMapV4_MzcxOTAxMTYy":{"properties":{"failed_to_list":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"qualified_user_client_prekeys":{"additionalProperties":{"$ref":"#/components/schemas/UserClientPrekeyMap"},"type":"object"}},"required":["qualified_user_client_prekeys"],"type":"object"},"QualifiedUserClients":{"additionalProperties":{"additionalProperties":{"items":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"type":"array"},"type":"object"},"description":"Map of Domain to UserClients","example":{"domain1.example.com":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":["60f85e4b15ad3786","6e323ab31554353b"]}},"type":"object"},"QualifiedUserMap_Set_PubClient":{"additionalProperties":{"$ref":"#/components/schemas/UserMap_Set_PubClient"},"description":"Map of Domain to (UserMap (Set_PubClient)).","example":{"domain1.example.com":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":[{"class":"legalhold","id":"d0"}]}},"type":"object"},"Qualified_Handle_Nzg0MDE3Nzk4":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"handle":{"$ref":"#/components/schemas/Handle"}},"required":["domain","handle"],"type":"object"},"Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","id"],"type":"object"},"Qualified_Id_IdTag_Meeting_MjgxMDUyNjc3":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","id"],"type":"object"},"Qualified_Id_IdTag_User_LTQ1NTIwNDM1":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","id"],"type":"object"},"QueuedNotificationList_MTU0ODEyNTQ2":{"description":"Zero or more notifications","properties":{"has_more":{"description":"Whether there are still more notifications.","type":"boolean"},"notifications":{"description":"Notifications","items":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"},"type":"array"},"time":{"$ref":"#/components/schemas/UTCTime"}},"required":["notifications"],"type":"object"},"QueuedNotification_NTY2NzY2MTU2":{"description":"A single notification","properties":{"id":{"$ref":"#/components/schemas/UUID"},"payload":{"description":"List of events","items":{"$ref":"#/components/schemas/Object"},"minItems":1,"type":"array"}},"required":["id","payload"],"type":"object"},"RTCConfiguration_LTIwOTc4OTk0":{"description":"A subset of the WebRTC 'RTCConfiguration' dictionary","properties":{"ice_servers":{"description":"Array of 'RTCIceServer' objects","items":{"$ref":"#/components/schemas/RTCIceServer_LTY1NzExODA0"},"minItems":1,"type":"array"},"is_federating":{"description":"True if the client should connect to an SFT in the sft_servers_all and request it to federate","type":"boolean"},"sft_servers":{"description":"Array of 'SFTServer' objects (optional)","items":{"$ref":"#/components/schemas/SFTServer_NDQ0NDkwNDE2"},"minItems":1,"type":"array"},"sft_servers_all":{"description":"Array of all SFT servers","items":{"$ref":"#/components/schemas/AuthSFTServer_LTY5MzcyOTE0"},"type":"array"},"ttl":{"description":"Number of seconds after which the configuration should be refreshed (advisory)","format":"int32","maximum":4294967295,"minimum":0,"type":"integer"}},"required":["ice_servers","ttl"],"type":"object"},"RTCIceServer_LTY1NzExODA0":{"description":"A subset of the WebRTC 'RTCIceServer' object","properties":{"credential":{"$ref":"#/components/schemas/ASCII"},"urls":{"description":"Array of TURN server addresses of the form 'turn::'","items":{"$ref":"#/components/schemas/TurnURI"},"minItems":1,"type":"array"},"username":{"$ref":"#/components/schemas/TurnUsername"}},"required":["urls","username","credential"],"type":"object"},"Recurrence_LTQ0OTc0ODE2":{"description":"Recurrence pattern for meetings","properties":{"frequency":{"$ref":"#/components/schemas/Frequency_Mzk0ODQwOTM3"},"interval":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"until":{"$ref":"#/components/schemas/UTCTime"}},"required":["frequency"],"type":"object"},"RedirectUrl":{"description":"The URL must match the URL that was used to generate the authorization code.","type":"string"},"RefreshAppCookieRequest_MjEyMDMyMTk5":{"properties":{"password":{"description":"The password of the authenticated admin for verification. or if the user has only SAML credentials.","maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"RefreshAppCookieResponse_LTQ0MjU1NTIw":{"properties":{"cookie":{"$ref":"#/components/schemas/SomeUserToken"}},"required":["cookie"],"type":"object"},"RegisteredDomains_V10_NDYwNzYyMTMy":{"properties":{"registered_domains":{"items":{"$ref":"#/components/schemas/DomainRegistrationResponse_V10_MjE0NDkxODY4"},"type":"array"}},"required":["registered_domains"],"type":"object"},"Relation_LTE4OTU5MTk4":{"enum":["accepted","blocked","pending","ignored","sent","cancelled","missing-legalhold-consent"],"type":"string"},"RemoveBotResponse_LTUxNTQ4MDEy":{"properties":{"event":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"required":["event"],"type":"object"},"RemoveCookies_OTYwMTI0NDMy":{"description":"Data required to remove cookies","properties":{"ids":{"description":"A list of cookie IDs to revoke","items":{"format":"int32","maximum":4294967295,"minimum":0,"type":"integer"},"type":"array"},"labels":{"description":"A list of cookie labels for which to revoke the cookies","items":{"type":"string"},"type":"array"},"password":{"description":"The user's password","maxLength":1024,"minLength":6,"type":"string"}},"required":["password"],"type":"object"},"RemoveLegalHoldSettingsRequest_NzMzMzMyMDAz":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"RichField_LTgwMzc0MTg2":{"properties":{"type":{"type":"string"},"value":{"type":"string"}},"required":["type","value"],"type":"object"},"RichInfoAssocList":{"description":"json object with case-insensitive fields.","properties":{"fields":{"items":{"$ref":"#/components/schemas/RichField_LTgwMzc0MTg2"},"type":"array"},"version":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["version","fields"],"type":"object"},"RmClient_MTQ5OTI2MDY3":{"properties":{"password":{"description":"The password of the authenticated user for verification. The password is not required for deleting temporary clients. or if the user has only SAML credentials.","maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"RoleName":{"description":"Role name, between 2 and 128 chars, 'wire_' prefix is reserved for roles designed by Wire (i.e., no custom roles can have the same prefix)","type":"string"},"Role_LTIzMjAzMjky":{"description":"Role of the invited user","enum":["owner","admin","member","partner"],"type":"string"},"SFTServer_NDQ0NDkwNDE2":{"description":"Inspired by WebRTC 'RTCIceServer' object, contains details of SFT servers","properties":{"urls":{"description":"Array containing exactly one SFT server address of the form 'https://:'","items":{"$ref":"#/components/schemas/HttpsUrl"},"type":"array"}},"required":["urls"],"type":"object"},"SFTUsername":{"description":"String containing the SFT username","type":"string"},"ScimTokenInfo_LTI5NjgwNzA1":{"properties":{"created_at":{"$ref":"#/components/schemas/UTCTime"},"description":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"idp":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"team":{"$ref":"#/components/schemas/UUID"}},"required":["team","id","created_at","description","name"],"type":"object"},"ScimTokenList_NjQwNTYxOTAw":{"properties":{"tokens":{"items":{"$ref":"#/components/schemas/ScimTokenInfo_LTI5NjgwNzA1"},"type":"array"}},"required":["tokens"],"type":"object"},"ScimTokenName_LTgzOTM2OTI4":{"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"},"SearchResult_Contact_OTExNzg4MTE0":{"properties":{"documents":{"description":"List of contacts found","items":{"$ref":"#/components/schemas/Contact_LTcwODE3Mjc5"},"type":"array"},"found":{"description":"Total number of hits","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"has_more":{"description":"Indicates whether there are more results to be fetched","type":"boolean"},"paging_state":{"$ref":"#/components/schemas/PagingState"},"returned":{"description":"Total number of hits returned","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"search_policy":{"$ref":"#/components/schemas/FederatedUserSearchPolicy_MzkwODA4MTM3"},"took":{"description":"Search time in ms","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["found","returned","took","documents","search_policy"],"type":"object"},"SearchResult_TeamContact_LTE0NjQ0NzMw":{"properties":{"documents":{"description":"List of contacts found","items":{"$ref":"#/components/schemas/TeamContact_LTI5MTIxODc0"},"type":"array"},"found":{"description":"Total number of hits","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"has_more":{"description":"Indicates whether there are more results to be fetched","type":"boolean"},"paging_state":{"$ref":"#/components/schemas/PagingState"},"returned":{"description":"Total number of hits returned","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"search_policy":{"$ref":"#/components/schemas/FederatedUserSearchPolicy_MzkwODA4MTM3"},"took":{"description":"Search time in ms","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["found","returned","took","documents","search_policy"],"type":"object"},"SelfDeletingMessagesConfigB_Covered_Identity_LTY0NzQ4MzU1":{"properties":{"enforcedTimeoutSeconds":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["enforcedTimeoutSeconds"],"type":"object"},"SendActivationCode_LTgyNDAxNzEy":{"description":"Data for requesting an email code to be sent. 'email' must be present.","properties":{"email":{"$ref":"#/components/schemas/Email"},"locale":{"$ref":"#/components/schemas/Locale"}},"required":["email"],"type":"object"},"SendVerificationCode_MjgxNDgxODE2":{"properties":{"action":{"$ref":"#/components/schemas/VerificationAction_LTU0MzYxNzUz"},"email":{"$ref":"#/components/schemas/Email"}},"required":["action","email"],"type":"object"},"ServerTime_LTM4NTI3MzIx":{"description":"The current server time","properties":{"time":{"$ref":"#/components/schemas/UTCTime"}},"required":["time"],"type":"object"},"ServiceKeyPEM":{"example":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu+Kg/PHHU3atXrUbKnw0\nG06FliXcNt3lMwl2os5twEDcPPFw/feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPH\nWvUBdiLfGrZqJO223DB6D8K2Su/odmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKV\nVPOaOzgtAB21XKRiQ4ermqgi3/njr03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiS\nbUKr/BeArYRcjzr/h5m1In6fG/if9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg8\n7X883H+LA/d6X5CTiPv1VMxXdBUiGPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7la\nnQIDAQAB\n-----END PUBLIC KEY-----\n","type":"string"},"ServiceKeyType_NTEzNzI4NTA2":{"enum":["rsa"],"type":"string"},"ServiceKey_NzY5NTY5NzYy":{"properties":{"pem":{"$ref":"#/components/schemas/ServiceKeyPEM"},"size":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"type":{"$ref":"#/components/schemas/ServiceKeyType_NTEzNzI4NTA2"}},"required":["type","size","pem"],"type":"object"},"ServiceProfilePage_Njg1NDQ5Njc4":{"properties":{"has_more":{"type":"boolean"},"services":{"items":{"$ref":"#/components/schemas/ServiceProfile_LTc2MDQzNTk3"},"type":"array"}},"required":["has_more","services"],"type":"object"},"ServiceProfile_LTc2MDQzNTk3":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"description":{"type":"string"},"enabled":{"type":"boolean"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"provider":{"$ref":"#/components/schemas/UUID"},"summary":{"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"type":"array"}},"required":["id","provider","name","summary","description","assets","tags","enabled"],"type":"object"},"ServiceRef_LTgxMjY3NzAz":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"provider":{"$ref":"#/components/schemas/UUID"}},"required":["id","provider"],"type":"object"},"ServiceTagList":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"type":"array"},"ServiceTag_LTMyNTEzNjYy":{"enum":["audio","books","business","design","education","entertainment","finance","fitness","food-drink","games","graphics","health","integration","lifestyle","media","medical","movies","music","news","photography","poll","productivity","quiz","rating","shopping","social","sports","travel","tutorial","video","weather"],"type":"string"},"Service_MjcyOTA5NjQx":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"auth_tokens":{"items":{"$ref":"#/components/schemas/ASCII"},"minItems":1,"type":"array"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"description":{"type":"string"},"enabled":{"type":"boolean"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"public_keys":{"items":{"$ref":"#/components/schemas/ServiceKey_NzY5NTY5NzYy"},"minItems":1,"type":"array"},"summary":{"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"type":"array"}},"required":["id","name","summary","description","base_url","auth_tokens","public_keys","assets","tags","enabled"],"type":"object"},"SetSearchable_NDAxODAxODI5":{"properties":{"set_searchable":{"type":"boolean"}},"required":["set_searchable"],"type":"object"},"SignedCertificate":{"type":"string"},"SimpleMember_NTY5MTcxMzcx":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"id":{"$ref":"#/components/schemas/UUID"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"}},"required":["qualified_id"],"type":"object"},"SomeKey":{},"SomeUserToken":{"type":"string"},"SsoSettings":{"properties":{"default_sso_code":{"$ref":"#/components/schemas/URI"}},"type":"object"},"Sso_LTg1MDM5ODQ3":{"properties":{"issuer":{"type":"string"},"nameid":{"type":"string"}},"required":["issuer","nameid"],"type":"object"},"SupportedProtocolUpdate_LTE3Njk3MDM4":{"properties":{"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"}},"required":["supported_protocols"],"type":"object"},"SystemSettingsPublic_LTgwNTMxNjU2":{"properties":{"nomadProfiles":{"description":"Whether Nomad client profiles are enabled; null or absence means not enabled.","type":"boolean"},"setRestrictUserCreation":{"description":"Do not allow certain user creation flows","type":"boolean"}},"required":["setRestrictUserCreation"],"type":"object"},"SystemSettings_ODU3MDk5MTA3":{"properties":{"nomadProfiles":{"description":"Whether Nomad client profiles are enabled; null or absence means not enabled.","type":"boolean"},"setEnableMls":{"description":"Whether MLS is enabled or not","type":"boolean"},"setRestrictUserCreation":{"description":"Do not allow certain user creation flows","type":"boolean"}},"required":["setRestrictUserCreation","setEnableMls"],"type":"object"},"TeamBinding_LTE4NTM5MTc0":{"deprecated":true,"description":"Deprecated, please ignore.","enum":[true,false],"type":"boolean"},"TeamCollaborator_LTI3MzM1MTYz":{"properties":{"permissions":{"items":{"$ref":"#/components/schemas/CollaboratorPermission_NDg5NTg2ODgy"},"type":"array"},"team":{"$ref":"#/components/schemas/UUID"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","team","permissions"],"type":"object"},"TeamContact_LTI5MTIxODc0":{"properties":{"accent_id":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"email":{"$ref":"#/components/schemas/Email"},"email_unvalidated":{"$ref":"#/components/schemas/Email"},"handle":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"managed_by":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"name":{"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"},"saml_idp":{"type":"string"},"scim_external_id":{"type":"string"},"searchable":{"type":"boolean"},"sso":{"$ref":"#/components/schemas/Sso_LTg1MDM5ODQ3"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"},"user_groups":{"description":"List of user group ids the user is a member of","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["id","type","name","user_groups","searchable"],"type":"object"},"TeamConversationList_OTI3MzY3NzY0":{"description":"Team conversation list","properties":{"conversations":{"items":{"$ref":"#/components/schemas/TeamConversation_LTIwNzgyNTEz"},"type":"array"}},"required":["conversations"],"type":"object"},"TeamConversation_LTIwNzgyNTEz":{"description":"Team conversation data","properties":{"conversation":{"$ref":"#/components/schemas/UUID"},"managed":{"description":"This field MUST NOT be used by clients. It is here only for backwards compatibility of the interface."}},"required":["conversation","managed"],"type":"object"},"TeamDeleteData_ODI5NTU0ODE5":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"type":"object"},"TeamDomainRedirectTag_MjQwMjc1Mjk3":{"enum":["no-registration","none"],"type":"string"},"TeamInviteConfig_MTg4Nzk4NzMz":{"properties":{"domain_redirect":{"$ref":"#/components/schemas/TeamDomainRedirectTag_MjQwMjc1Mjk3"},"sso":{"example":"99db9768-04e3-4b5d-9268-831b6a25c4ab","format":"uuid","type":"string"},"team":{"$ref":"#/components/schemas/UUID"},"team_invite":{"$ref":"#/components/schemas/TeamInviteTag_LTQyNTMyNzA0"}},"required":["team_invite","team"],"type":"object"},"TeamInviteTag_LTQyNTMyNzA0":{"enum":["allowed","not-allowed","team"],"type":"string"},"TeamMemberDeleteData_LTg2OTEyOTI4":{"description":"Data for a team member deletion request in case of binding teams.","properties":{"password":{"description":"The account password to authorise the deletion.","maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"TeamMemberList_Optional_LTM1ODE2MzM0":{"description":"list of team member","properties":{"hasMore":{"$ref":"#/components/schemas/ListType_LTkyMDM4MzA1"},"members":{"description":"the array of team members","items":{"$ref":"#/components/schemas/TeamMember_Optional_NTU0MDcyNzI1"},"type":"array"}},"required":["members","hasMore"],"type":"object"},"TeamMember_Optional_NTU0MDcyNzI1":{"description":"team member data","properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"legalhold_status":{"$ref":"#/components/schemas/UserLegalHoldStatus_LTQ2ODA2NTU5"},"permissions":{"$ref":"#/components/schemas/Permissions_NDE0ODM5NDUx"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user"],"type":"object"},"TeamMembersPage_NzYwNDIxODgx":{"properties":{"hasMore":{"type":"boolean"},"members":{"items":{"$ref":"#/components/schemas/TeamMember_Optional_NTU0MDcyNzI1"},"type":"array"},"pagingState":{"$ref":"#/components/schemas/TeamMembers_PagingState"}},"required":["members","hasMore","pagingState"],"type":"object"},"TeamMembers_PagingState":{"type":"string"},"TeamSearchVisibilityView_Mzg3MzMzMTk3":{"description":"Search visibility value for the team","properties":{"search_visibility":{"$ref":"#/components/schemas/TeamSearchVisibility_LTIzODE2Njk3"}},"required":["search_visibility"],"type":"object"},"TeamSearchVisibility_LTIzODE2Njk3":{"description":"value of visibility","enum":["standard","no-name-outside-team"],"type":"string"},"TeamSize_LTMzMzk2MTk1":{"description":"Team member counts broken down by user type.","properties":{"teamSize":{"description":"Total team members (teamSizeRegulars + teamSizeApps).","exclusiveMinimum":false,"minimum":0,"type":"integer"},"teamSizeApps":{"description":"Number of apps in team.","exclusiveMinimum":false,"minimum":0,"type":"integer"},"teamSizeRegulars":{"description":"Number of regular users in team.","exclusiveMinimum":false,"minimum":0,"type":"integer"}},"required":["teamSizeRegulars","teamSizeApps"],"type":"object"},"TeamUpdateData_LTE0NTM2NTU5":{"properties":{"icon":{"$ref":"#/components/schemas/Icon"},"icon_key":{"maxLength":256,"minLength":1,"type":"string"},"name":{"maxLength":256,"minLength":1,"type":"string"},"splash_screen":{"$ref":"#/components/schemas/Icon"}},"type":"object"},"Team_NDg4MjQwOTIw":{"description":"`binding` is deprecated, and should be ignored. The non-binding teams API is not used (and will not be supported from API version V4 onwards), and `binding` will always be `true`.","properties":{"binding":{"$ref":"#/components/schemas/TeamBinding_LTE4NTM5MTc0"},"creator":{"$ref":"#/components/schemas/UUID"},"icon":{"$ref":"#/components/schemas/Icon"},"icon_key":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"splash_screen":{"$ref":"#/components/schemas/Icon"}},"required":["id","creator","name","icon"],"type":"object"},"Time":{"properties":{"time":{"$ref":"#/components/schemas/UTCTime"}},"required":["time"],"type":"object"},"Token":{"example":"ZXhhbXBsZQo=","type":"string"},"TokenType_NTkyMzk4MjIz":{"enum":["Bearer"],"type":"string"},"Transport_NDk2NzU5NDIy":{"description":"Transport","enum":["GCM","APNS","APNS_SANDBOX","APNS_VOIP","APNS_VOIP_SANDBOX"],"type":"string"},"TurnURI":{"type":"string"},"TurnUsername":{"description":"Username to use for authenticating against the given TURN servers","type":"string"},"TypingStatus_LTg5MzcyNDMy":{"enum":["started","stopped"],"type":"string"},"URI":{"type":"string"},"URIRef_Absolute":{"description":"URL of the invitation link to be sent to the invitee","type":"string"},"UTCTime":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"},"UTCTimeMillis":{"description":"The time when the session was created","example":"2021-05-12T10:52:02.671Z","format":"yyyy-mm-ddThh:MM:ss.qqqZ","type":"string"},"UUID":{"description":"The OAuth client's ID","example":"99db9768-04e3-4b5d-9268-831b6a25c4ab","format":"uuid","type":"string"},"UncheckedPrekeyBundle_LTU1MzQzOTgy":{"properties":{"id":{"maximum":65535,"minimum":0,"type":"integer"},"key":{"type":"string"}},"required":["id","key"],"type":"object"},"UpdateBotPrekeys_LTg3NzYxODg0":{"properties":{"prekeys":{"items":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"type":"array"}},"required":["prekeys"],"type":"object"},"UpdateClient_NzU5MjA4MzI1":{"properties":{"capabilities":{"$ref":"#/components/schemas/ClientCapabilityList"},"label":{"description":"A new name for this client.","type":"string"},"lastkey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"mls_public_keys":{"$ref":"#/components/schemas/MLSPublicKeys"},"prekeys":{"description":"New prekeys for other clients to establish OTR sessions.","items":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"type":"array"}},"type":"object"},"UpdateMeeting_NTExNzYxMTcz":{"description":"Request to update a meeting","properties":{"end_time":{"$ref":"#/components/schemas/UTCTime"},"recurrence":{"$ref":"#/components/schemas/Recurrence_LTQ0OTc0ODE2"},"start_time":{"$ref":"#/components/schemas/UTCTime"},"title":{"maxLength":256,"minLength":1,"type":"string"}},"type":"object"},"UpdateProvider_LTQwMjY4MDgy":{"properties":{"description":{"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"url":{"$ref":"#/components/schemas/HttpsUrl"}},"type":"object"},"UpdateServiceConn_LTQ1OTYwNjIz":{"properties":{"auth_tokens":{"items":{"$ref":"#/components/schemas/ASCII"},"maxItems":2,"minItems":1,"type":"array"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"enabled":{"type":"boolean"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"public_keys":{"items":{"$ref":"#/components/schemas/ServiceKeyPEM"},"maxItems":2,"minItems":1,"type":"array"}},"required":["password"],"type":"object"},"UpdateServiceWhitelist_LTU5MDAwMTIw":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"provider":{"$ref":"#/components/schemas/UUID"},"whitelisted":{"type":"boolean"}},"required":["provider","id","whitelisted"],"type":"object"},"UpdateService_MjAxNzQ2Njkz":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"description":{"maxLength":1024,"minLength":1,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"summary":{"maxLength":128,"minLength":1,"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"maxItems":3,"minItems":1,"type":"array"}},"type":"object"},"UpdateUserGroupChannels_LTIyMjcwMTMx":{"properties":{"channels":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["channels"],"type":"object"},"UpdateUserGroupMembers_LTg1MzQ2NDY3":{"properties":{"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["members"],"type":"object"},"UserClientMap":{"additionalProperties":{"additionalProperties":{"type":"string"},"type":"object"},"type":"object"},"UserClientPrekeyMap":{"additionalProperties":{"additionalProperties":{"properties":{"id":{"maximum":65535,"minimum":0,"type":"integer"},"key":{"type":"string"}},"required":["id","key"],"type":"object"},"type":"object"},"example":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":{"44901fb0712e588f":{"id":1,"key":"pQABAQECoQBYIOjl7hw0D8YRNq..."}}},"type":"object"},"UserClients":{"additionalProperties":{"items":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"type":"array"},"description":"Map of user id to list of client ids.","example":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":["60f85e4b15ad3786","6e323ab31554353b"]},"type":"object"},"UserConnection_LTY3NzU1ODg0":{"properties":{"conversation":{"$ref":"#/components/schemas/UUID"},"from":{"$ref":"#/components/schemas/UUID"},"last_update":{"$ref":"#/components/schemas/UTCTimeMillis"},"qualified_conversation":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_to":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"status":{"$ref":"#/components/schemas/Relation_LTE4OTU5MTk4"},"to":{"$ref":"#/components/schemas/UUID"}},"required":["from","qualified_to","status","last_update"],"type":"object"},"UserGroupAddUsers_LTgzOTYzNzk0":{"properties":{"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["members"],"type":"object"},"UserGroupNameAvailability_LTYzMDE1NTk4":{"properties":{"name_available":{"type":"boolean"}},"required":["name_available"],"type":"object"},"UserGroupPage_UserGroup_Const_LTMxNDg5MDAy":{"description":"This is the last page if it contains fewer rows than requested. There may be 0 rows on a page.","properties":{"page":{"items":{"$ref":"#/components/schemas/UserGroup_Const_NTMzOTAzMzA1"},"type":"array"},"total":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["page","total"],"type":"object"},"UserGroupUpdate_MjUyNTA3Mjgy":{"properties":{"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["name"],"type":"object"},"UserGroup_Const_NTMzOTAzMzA1":{"properties":{"channels":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"},"channelsCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"createdAt":{"$ref":"#/components/schemas/UTCTimeMillis"},"id":{"$ref":"#/components/schemas/UUID"},"managedBy":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"membersCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["id","name","managedBy","createdAt"],"type":"object"},"UserGroup_Identity_NTg4MTY1MjEx":{"properties":{"channels":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"},"channelsCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"createdAt":{"$ref":"#/components/schemas/UTCTimeMillis"},"id":{"$ref":"#/components/schemas/UUID"},"managedBy":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"membersCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["id","name","members","managedBy","createdAt"],"type":"object"},"UserIdList_MzA1MTI1Njgx":{"properties":{"user_ids":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["user_ids"],"type":"object"},"UserLegalHoldStatusResponse_LTQ1MzUxMTE3":{"properties":{"client":{"$ref":"#/components/schemas/IdObject_ClientId_LTM3NjQyODM5"},"last_prekey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"status":{"$ref":"#/components/schemas/UserLegalHoldStatus_LTQ2ODA2NTU5"}},"required":["status"],"type":"object"},"UserLegalHoldStatus_LTQ2ODA2NTU5":{"description":"The state of Legal Hold compliance for the member","enum":["enabled","pending","disabled","no_consent"],"type":"string"},"UserMap_Set_PubClient":{"additionalProperties":{"items":{"$ref":"#/components/schemas/PubClient"},"type":"array","uniqueItems":true},"description":"Map of UserId to (Set PubClient)","example":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":[{"class":"legalhold","id":"d0"}]},"type":"object"},"UserProfile_LTQzMTQxMTE1":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"app":{"$ref":"#/components/schemas/AppInfo_MjgwNTkwOTUz"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"deleted":{"type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"expires_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"handle":{"$ref":"#/components/schemas/Handle"},"id":{"$ref":"#/components/schemas/UUID"},"legalhold_status":{"$ref":"#/components/schemas/UserLegalHoldStatus_LTQ2ODA2NTU5"},"name":{"maxLength":128,"minLength":1,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"searchable":{"type":"boolean"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"},"team":{"$ref":"#/components/schemas/UUID"},"text_status":{"maxLength":256,"minLength":1,"type":"string"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"}},"required":["qualified_id","name","accent_id","legalhold_status"],"type":"object"},"UserSSOId":{"properties":{"scim_external_id":{"type":"string"},"subject":{"type":"string"},"tenant":{"type":"string"}},"type":"object"},"UserType_LTU1OTU4OTM5":{"enum":["regular","app","bot"],"type":"string"},"UserUpdate_MjQ4NTEwOTQz":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"name":{"maxLength":128,"minLength":1,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"text_status":{"maxLength":256,"minLength":1,"type":"string"}},"type":"object"},"User_NjA4OTQwMTQ4":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"deleted":{"type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"email_unvalidated":{"$ref":"#/components/schemas/Email"},"expires_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"handle":{"$ref":"#/components/schemas/Handle"},"id":{"$ref":"#/components/schemas/UUID"},"locale":{"$ref":"#/components/schemas/Locale"},"managed_by":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"name":{"maxLength":128,"minLength":1,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"searchable":{"type":"boolean"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"sso_id":{"$ref":"#/components/schemas/UserSSOId"},"status":{"$ref":"#/components/schemas/AccountStatus_NzkzNDU1ODU5"},"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"},"team":{"$ref":"#/components/schemas/UUID"},"text_status":{"maxLength":256,"minLength":1,"type":"string"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"}},"required":["qualified_id","type","name","accent_id","status","locale"],"type":"object"},"VerificationAction_LTU0MzYxNzUz":{"enum":["create_scim_token","login","delete_team"],"type":"string"},"VerifyDeleteUser_Njc1NDQ1MDIy":{"description":"Data for verifying an account deletion.","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"}},"required":["key","code"],"type":"object"},"VersionInfo_NTEzMTgzNDQ0":{"example":{"development":[16],"domain":"example.com","federation":false,"supported":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]},"properties":{"development":{"items":{"$ref":"#/components/schemas/VersionNumber_Njk2NzI5Njk1"},"type":"array"},"domain":{"$ref":"#/components/schemas/Domain"},"federation":{"type":"boolean"},"supported":{"items":{"$ref":"#/components/schemas/VersionNumber_Njk2NzI5Njk1"},"type":"array"}},"required":["supported","development","federation","domain"],"type":"object"},"VersionNumber_Njk2NzI5Njk1":{"enum":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16],"type":"integer"},"ViewLegalHoldServiceInfo_LTc3NjI2MzQ3":{"properties":{"auth_token":{"$ref":"#/components/schemas/ASCII"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"fingerprint":{"$ref":"#/components/schemas/Fingerprint"},"public_key":{"$ref":"#/components/schemas/ServiceKeyPEM"},"team_id":{"$ref":"#/components/schemas/UUID"}},"required":["team_id","base_url","fingerprint","auth_token","public_key"],"type":"object"},"ViewLegalHoldService_LTE3MzQzNDkw":{"properties":{"settings":{"$ref":"#/components/schemas/ViewLegalHoldServiceInfo_LTc3NjI2MzQ3"},"status":{"$ref":"#/components/schemas/LHServiceStatus_ODc3NzE0Mjg3"}},"required":["status"],"type":"object"},"WireIdPAPIVersion_NTEyMzIwNTU3":{"enum":["WireIdPAPIV1","WireIdPAPIV2"],"type":"string"},"WireIdP_ODMzOTExMzYw":{"properties":{"apiVersion":{"enum":["WireIdPAPIV1","WireIdPAPIV2"],"type":"string"},"domain":{"type":"string"},"handle":{"type":"string"},"oldIssuers":{"items":{"$ref":"#/components/schemas/URI"},"type":"array"},"replacedBy":{"type":"string"},"team":{"$ref":"#/components/schemas/UUID"}},"required":["team","apiVersion","oldIssuers","replacedBy","handle","domain"],"type":"object"},"v2_ConversationAccessData_MjMxMTI5ODc3":{"properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"$ref":"#/components/schemas/AccessRoleLegacy_LTYwOTAxMDI1"},"access_role_v2":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"}},"required":["access"],"type":"object"},"v2_OwnConversation_NDU4NDc3MDgz":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"$ref":"#/components/schemas/AccessRoleLegacy_LTYwOTAxMDI1"},"access_role_v2":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/EpochTimestamp"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","members","group_id","epoch","epoch_timestamp","cipher_suite"],"type":"object"},"v3_OwnConversation_NDU4NDc3MDgzV3":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/EpochTimestamp"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch","epoch_timestamp","cipher_suite"],"type":"object"},"v6_OwnConversation_NDU4NDc3MDgzV6":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"},"v9_OwnConversation_NDU4NDc3MDgz":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"}},"securitySchemes":{"ZAuth":{"description":"Must be a token retrieved by calling 'POST /login' or 'POST /access'. It must be presented in this format: 'Bearer \\'.","in":"header","name":"Authorization","type":"apiKey"}}},"info":{"description":"## Authentication / Authorization\n\nThe end-points in this API support differing authorization protocols:\nsome are unauthenticated (`/api-version`, `/login`), some require\n[zauth](), and some support both [zauth]() and [oauth]().\n\nThe end-points that require zauth are labelled so in the description\nbelow. The end-points that support oauth as an alternative to zauth\nhave the required oauth scopes listed in the same description.\n\nFuther reading:\n- https://docs.wire.com/developer/reference/oauth.html\n- https://github.com/wireapp/wire-server/blob/develop/libs/wire-api/src/Wire/API/Routes/Public.hs (search for HasSwagger instances)\n- `curl https://staging-nginz-https.zinfra.io/v4/api/swagger.json | jq '.security, .securityDefinitions`\n\n### SSO Endpoints\n\n#### Overview\n\n`/sso/metadata` will be requested by the IdPs to learn how to talk to wire.\n\n`/sso/initiate-login`, `/sso/finalize-login` are for the SAML authentication handshake performed by a user in order to log into wire. They are not exactly standard in their details: they may return HTML or XML; redirect to error URLs instead of throwing errors, etc.\n\n`/identity-providers` end-points are for use in the team settings page when IdPs are registered. They talk json.\n\n\n#### Configuring IdPs\n\nIdPs usually allow you to copy the metadata into your clipboard. That should contain all the details you need to post the idp in your team under `/identity-providers`. (Team id is derived from the authorization credentials of the request.)\n\n##### okta.com\n\nOkta will ask you to provide two URLs when you set it up for talking to wireapp:\n\n1. The `Single sign on URL`. This is the end-point that accepts the user's credentials after successful authentication against the IdP. Choose `/sso/finalize-login` with schema and hostname of the wire server you are configuring.\n\n2. The `Audience URI`. You can find this in the metadata returned by the `/sso/metadata` end-point. It is the contents of the `md:OrganizationURL` element.\n\n##### centrify.com\n\nCentrify allows you to upload the metadata xml document that you get from the `/sso/metadata` end-point. You can also enter the metadata url and have centrify retrieve the xml, but to guarantee integrity of the setup, the metadata should be copied from the team settings page and pasted into the centrify setup page without any URL indirections.\n\n## Federation errors\n\nEndpoints involving federated calls to other domains can return some extra failure responses, common to all endpoints. Instead of listing them as possible responses for each endpoint, we document them here.\n\nFor errors that are more likely to be transient, we suggest clients to retry whatever request resulted in the error. Transient errors are indicated explicitly below.\n\n**Note**: when a failure occurs as a result of making a federated RPC to another backend, the error response contains the following extra fields:\n\n - `type`: \"federation\" (just the literal string in quotes, which can be used as an error type identifier when parsing errors)\n - `domain`: the target backend of the RPC that failed;\n - `path`: the path of the RPC that failed.\n\n### Domain errors\n\nErrors in this category result from trying to communicate with a backend that is considered non-existent or invalid. They can result from invalid user input or client issues, but they can also be a symptom of misconfiguration in one or multiple backends. These errors have a 4xx status code.\n\n - **Remote backend not found** (status: 422, label: `invalid-domain`): This backend attempted to contact a backend which does not exist or is not properly configured. For the most part, clients can consider this error equivalent to a domain not existing, although it should be noted that certain mistakes in the DNS configuration on a remote backend can lead to the backend not being recognized, and hence to this error. It is therefore not advisable to take any destructive action upon encountering this error, such as deleting remote users from conversations.\n - **Federation denied locally** (status: 400, label: `federation-denied`): This backend attempted an RPC to a non-whitelisted backend. Similar considerations as for the previous error apply.\n - **Federation not enabled** (status: 400, label: `federation-not-enabled`): Federation has not been configured for this backend. This will happen if a federation-aware client tries to talk to a backend for which federation is disabled, or if federation was disabled on the backend after reaching a federation-specific state (e.g. conversations with remote users). There is no way to cleanly recover from these errors at this point.\n\n### Local federation errors\n\nAn error in this category likely indicates an issue with the configuration of federation on the local backend. Possibly transient errors are indicated explicitly below. All these errors have a 500 status code.\n\n - **Federation unavailable** (status: 500, label: `federation-not-available`): Federation is configured for this backend, but the local federator cannot be reached. This can be transient, so clients should retry the request.\n - **Federation not implemented** (status: 422, label: `federation-not-implemented`): Federated behaviour for a certain endpoint is not yet implemented.\n - **Federator discovery failed** (status: 400, label: `discovery-failure`): A DNS error occurred during discovery of a remote backend. This can be transient, so clients should retry the request.\n - **Local federation error** (status: 500, label: `federation-local-error`): An error occurred in the communication between this backend and its local federator. These errors are most likely caused by bugs in the backend, and should be reported as such.\n\n### Remote federation errors\n\nErrors in this category are returned in case of communication issues between the local backend and a remote one, or if the remote side encountered an error while processing an RPC. Some errors in this category might be caused by incorrect client behaviour, wrong user input, or incorrect certificate configuration. Possibly transient errors are indicated explicitly. We use non-standard 5xx status codes for these errors.\n\n - **HTTP2 error** (status: 533, label: `federation-http2-error`): The current federator encountered an error when making an HTTP2 request to a remote one. Check the error message for more details.\n - **Connection refused** (status: 521, label: `federation-connection-refused`): The local federator could not connect to a remote one. This could be transient, so clients should retry the request.\n - **TLS failure**: (status: 525, label: `federation-tls-error`): An error occurred during the TLS handshake between the local federator and a remote one. This is most likely due to an issue with the certificate on the remote end.\n - **Remote federation error** (status: 533, label: `federation-remote-error`): The remote backend could not process a request coming from this backend. Check the error message for more details.\n - **Version negotiation error** (status: 533, label: `federation-version-error`): The remote backend returned invalid version information.\n\n### Backend compatibility errors\n\nAn error in this category will be returned when this backend makes an invalid or unsupported RPC to another backend. This can indicate some incompatibility between backends or a backend bug. These errors are unlikely to be transient, so retrying requests is *not* advised.\n\n - **Version mismatch** (status: 531, label: `federation-version-mismatch`): A remote backend is running an unsupported version of the federator.\n - **Invalid content type** (status: 533, label: `federation-invalid-content-type`): An RPC to another backend returned with an invalid content type.\n - **Unsupported content type** (status: 533, label: `federation-unsupported-content-type`): An RPC to another backend returned with an unsupported content type.\n","title":"Wire-Server API","version":""},"openapi":"3.0.0","paths":{"/access":{"post":{"description":" [internal route ID: \"access\"]\n\nYou can provide only a cookie or a cookie and token. Every other combination is invalid. Access tokens can be given as query parameter or authorisation header, with the latter being preferred.","operationId":"access","parameters":[{"in":"query","name":"client_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)"}},"summary":"Obtain an access tokens for a cookie"}},"/access/logout":{"post":{"description":" [internal route ID: \"logout\"]\n\nCalling this endpoint will effectively revoke the given cookie and subsequent calls to /access with the same cookie will result in a 403.","operationId":"logout","responses":{"200":{"description":"Logout"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)"}},"summary":"Log out in order to remove a cookie from the server"}},"/access/self/email":{"put":{"description":" [internal route ID: \"change-self-email\"]\n\n","operationId":"change-self-email","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/EmailUpdate_NjQ5MDg1OTY0"}}},"required":true},"responses":{"202":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"Update accepted and pending activation of the new email"},"204":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"No update, current and new email address are the same\n\nEmail address activated"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid e-mail address. (label: `invalid-email`) or `body`"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","blacklisted-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}},"summary":"Change your email address"}},"/activate":{"get":{"description":" [internal route ID: \"get-activate\"]\n\nSee also 'POST /activate' which has a larger feature set.","operationId":"get-activate","parameters":[{"description":"Activation key","in":"query","name":"key","required":true,"schema":{"type":"string"}},{"description":"Activation code","in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}}},"description":"Activation successful.\n\nActivation successful. (Dry run)\n\nActivation successful."},"204":{"description":"A recent activation was already successful."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-phone","message":"Invalid mobile phone number"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-phone","invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `code` or `key`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"Invalid activation code"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}},"summary":"Activate (i.e. confirm) an email address."},"post":{"description":" [internal route ID: \"post-activate\"]\n\nActivation only succeeds once and the number of failed attempts for a valid key is limited.","operationId":"post-activate","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Activate_MzUzNzIxODUw"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}}},"description":"Activation successful.\n\nActivation successful. (Dry run)\n\nActivation successful."},"204":{"description":"A recent activation was already successful."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-phone","message":"Invalid mobile phone number"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-phone","invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"Invalid activation code"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}},"summary":"Activate (i.e. confirm) an email address."}},"/activate/send":{"post":{"description":" [internal route ID: \"post-activate-send\"]\n\n","operationId":"post-activate-send","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SendActivationCode_LTgyNDAxNzEy"}}},"required":true},"responses":{"200":{"description":"Activation code sent."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"blacklisted-email","message":"The given e-mail address has been blacklisted due to a permanent bounce or a complaint."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["blacklisted-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"},"451":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":451,"label":"domain-blocked-for-registration","message":"[Customer extension] The email domain has been blocked for Wire users. Please contact your IT department."},"properties":{"code":{"enum":[451],"type":"integer"},"label":{"enum":["domain-blocked-for-registration"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"[Customer extension] The email domain has been blocked for Wire users. Please contact your IT department. (label: `domain-blocked-for-registration`)"}},"summary":"Send (or resend) an email activation code."}},"/api-version":{"get":{"description":" [internal route ID: \"get-version\"]\n\n","operationId":"get-version","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/VersionInfo_NTEzMTgzNDQ0"}}},"description":""}}}},"/assets":{"post":{"description":" [internal route ID: \"assets-upload\"]\n\n

Construct the request as multipart/mixed; set header Content-Type: multipart/mixed; boundary=<boundary>.

Use exactly two parts in this order:

  1. application/json metadata (AssetSettings)
  2. application/octet-stream asset bytes

Each part must include Content-Type and Content-Length; the second part may include Content-MD5. Use CRLF between headers and bodies.

When asset audit logging is enabled, the JSON metadata must include:

  • convId: object { id: UUID, domain: String } (qualified conversation ID)
  • filename: String
  • filetype: String MIME type (e.g. image/png, application/pdf)

Optional metadata: public (Bool, default false), retention (one of eternal, persistent, volatile, eternal-infrequent_access, expiring).

For profile pictures or team icons without a conversation, set convId.id to 00000000-0000-0000-0000-000000000000 and convId.domain to the tenant’s domain; use any reasonable filename.

Note: the server treats the asset bytes as application/octet-stream; filetype is used for auditing only.

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

--frontier
Content-Type: application/json
Content-Length: 191

{\"public\":false,\"retention\":\"volatile\",\"convId\":{\"id\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"domain\":\"example.com\"},\"filename\":\"report.pdf\",\"filetype\":\"application/pdf\"}
--frontier
Content-Type: application/octet-stream
Content-Length: 11

Hello Audit
--frontier--
","operationId":"assets-upload","requestBody":{"content":{"multipart/mixed":{"schema":{"$ref":"#/components/schemas/AssetSource"}}},"description":"A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server."},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}}},"description":"Asset posted","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"incomplete-body","message":"HTTP content-length header does not match body size"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["incomplete-body","invalid-length"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nHTTP content-length header does not match body size (label: `incomplete-body`)\n\nInvalid content length (label: `invalid-length`)"},"413":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"client-error","message":"Asset too large"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["client-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Asset too large (label: `client-error`)"}},"summary":"Upload an asset"}},"/assets/{key_domain}/{key}":{"delete":{"description":" [internal route ID: \"assets-delete\"]\n\n**Note**: only local assets can be deleted.","operationId":"assets-delete","parameters":[{"in":"path","name":"key_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key_domain` or `key` not found\n\nAsset not found (label: `not-found`)"}},"summary":"Delete an asset"},"get":{"description":" [internal route ID: \"assets-download\"]\n\n**Note**: local assets result in a redirect, while remote assets are streamed directly.","operationId":"assets-download","parameters":[{"in":"path","name":"key_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"key","required":true,"schema":{"type":"string"}},{"in":"header","name":"Asset-Token","required":false,"schema":{"type":"string"}},{"in":"query","name":"asset_token","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset returned directly with content type `application/octet-stream`"},"302":{"description":"Asset found","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key_domain` or `key` or Asset not found (label: `not-found`)\n\nAsset not found (label: `not-found`)"}},"summary":"Download an asset"}},"/assets/{key}/token":{"delete":{"description":" [internal route ID: \"tokens-delete\"]\n\n**Note**: deleting the token makes the asset public.","operationId":"tokens-delete","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset token deleted"}},"summary":"Delete an asset token"},"post":{"description":" [internal route ID: \"tokens-renew\"]\n\n","operationId":"tokens-renew","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewAssetToken_NTAwMDQwODYy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` not found\n\nAsset not found (label: `not-found`)"}},"summary":"Renew an asset token"}},"/await":{"get":{"description":" [internal route ID: \"await-notifications\"]\n\n","externalDocs":{"description":"RFC 6455","url":"https://datatracker.ietf.org/doc/html/rfc6455"},"operationId":"await-notifications","parameters":[{"description":"Client ID","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"101":{"description":"Connection upgraded."},"426":{"description":"Upgrade required."}},"summary":"Establish websocket connection"}},"/bot/assets":{"post":{"description":" [internal route ID: (\"assets-upload-v3\", bot)]\n\n

Construct the request as multipart/mixed; set header Content-Type: multipart/mixed; boundary=<boundary>.

Use exactly two parts in this order:

  1. application/json metadata (AssetSettings)
  2. application/octet-stream asset bytes

Each part must include Content-Type and Content-Length; the second part may include Content-MD5. Use CRLF between headers and bodies.

When asset audit logging is enabled, the JSON metadata must include:

  • convId: object { id: UUID, domain: String } (qualified conversation ID)
  • filename: String
  • filetype: String MIME type (e.g. image/png, application/pdf)

Optional metadata: public (Bool, default false), retention (one of eternal, persistent, volatile, eternal-infrequent_access, expiring).

For profile pictures or team icons without a conversation, set convId.id to 00000000-0000-0000-0000-000000000000 and convId.domain to the tenant’s domain; use any reasonable filename.

Note: the server treats the asset bytes as application/octet-stream; filetype is used for auditing only.

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

--frontier
Content-Type: application/json
Content-Length: 191

{\"public\":false,\"retention\":\"volatile\",\"convId\":{\"id\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"domain\":\"example.com\"},\"filename\":\"report.pdf\",\"filetype\":\"application/pdf\"}
--frontier
Content-Type: application/octet-stream
Content-Length: 11

Hello Audit
--frontier--
","operationId":"assets-upload-v3_bot","requestBody":{"content":{"multipart/mixed":{"schema":{"$ref":"#/components/schemas/AssetSource"}}},"description":"A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server."},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}}},"description":"Asset posted","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"incomplete-body","message":"HTTP content-length header does not match body size"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["incomplete-body","invalid-length"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nHTTP content-length header does not match body size (label: `incomplete-body`)\n\nInvalid content length (label: `invalid-length`)"},"413":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"client-error","message":"Asset too large"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["client-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Asset too large (label: `client-error`)"}},"summary":"Upload an asset"}},"/bot/assets/{key}":{"delete":{"description":" [internal route ID: (\"assets-delete-v3\", bot)]\n\n","operationId":"assets-delete-v3_bot","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` not found\n\nAsset not found (label: `not-found`)"}},"summary":"Delete an asset"},"get":{"description":" [internal route ID: (\"assets-download-v3\", bot)]\n\n","operationId":"assets-download-v3_bot","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}},{"in":"header","name":"Asset-Token","required":false,"schema":{"type":"string"}},{"in":"query","name":"asset_token","required":false,"schema":{"type":"string"}}],"responses":{"302":{"description":"Asset found","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` or Asset not found (label: `not-found`)"}},"summary":"Download an asset"}},"/bot/client":{"get":{"description":" [internal route ID: \"bot-get-client\"]\n\n","operationId":"bot-get-client","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}}},"description":"Client found"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"client-not-found","message":"Client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["client-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"client-not-found","message":"Client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["client-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Client not found (label: `client-not-found`)\n\nClient not found (label: `client-not-found`)"}},"summary":"Get client for bot"}},"/bot/client/prekeys":{"get":{"description":" [internal route ID: \"bot-list-prekeys\"]\n\n","operationId":"bot-list-prekeys","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"maximum":65535,"minimum":0,"type":"integer"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"List prekeys for bot"},"post":{"description":" [internal route ID: \"bot-update-prekeys\"]\n\n","operationId":"bot-update-prekeys","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateBotPrekeys_LTg3NzYxODg0"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"client-not-found","message":"Client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["client-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Client not found (label: `client-not-found`)"}},"summary":"Update prekeys for bot"}},"/bot/conversation":{"get":{"description":" [internal route ID: \"get-bot-conversation\"]\n\n","operationId":"get-bot-conversation","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/BotConvView_LTYzMjIzMjQz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)"}}}},"/bot/conversations/{conv}":{"post":{"description":" [internal route ID: \"add-bot\"]\n\n","operationId":"add-bot","parameters":[{"in":"path","name":"conv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AddBot_NjI0ODkyODk3"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddBotResponse_ODA5MzA2NTA1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AddBotResponse_ODA5MzA2NTA1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"service-disabled","message":"The desired service is currently disabled."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["service-disabled","too-many-members","invalid-conversation","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The desired service is currently disabled. (label: `service-disabled`)\n\nMaximum number of members per conversation reached. (label: `too-many-members`)\n\nThe operation is not allowed in this conversation. (label: `invalid-conversation`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Add bot"}},"/bot/conversations/{conv}/{bot}":{"delete":{"description":" [internal route ID: \"remove-bot\"]\n\n","operationId":"remove-bot","parameters":[{"in":"path","name":"conv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"bot","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveBotResponse_LTUxNTQ4MDEy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RemoveBotResponse_LTUxNTQ4MDEy"}}},"description":"User found"},"204":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-conversation","message":"The operation is not allowed in this conversation."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-conversation","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The operation is not allowed in this conversation. (label: `invalid-conversation`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Remove bot"}},"/bot/messages":{"post":{"description":" [internal route ID: \"post-bot-message-unqualified\"]\n\n","operationId":"post-bot-message-unqualified","parameters":[{"in":"query","name":"ignore_missing","required":false,"schema":{"type":"string"}},{"in":"query","name":"report_missing","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Message sent"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation not found (label: `no-conversation`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Missing clients"}}}},"/bot/self":{"delete":{"description":" [internal route ID: \"bot-delete-self\"]\n\n","operationId":"bot-delete-self","responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-bot","message":"The targeted user is not a bot."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-bot","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The targeted user is not a bot. (label: `invalid-bot`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Delete self"},"get":{"description":" [internal route ID: \"bot-get-self\"]\n\n","operationId":"bot-get-self","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"User not found (label: `not-found`)"}},"summary":"Get self"}},"/bot/users":{"get":{"description":" [internal route ID: \"bot-list-users\"]\n\n","operationId":"bot-list-users","parameters":[{"in":"query","name":"ids","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/BotUserView_LTE2MTkwMTcw"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"List users"}},"/bot/users/prekeys":{"post":{"description":" [internal route ID: \"bot-claim-users-prekeys\"]\n\n","operationId":"bot-claim-users-prekeys","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserClients"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserClientPrekeyMap"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","missing-legalhold-consent-old-clients","too-many-clients","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nToo many clients (label: `too-many-clients`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Claim users prekeys"}},"/bot/users/{user}/clients":{"get":{"description":" [internal route ID: \"bot-get-user-clients\"]\n\n","operationId":"bot-get-user-clients","parameters":[{"in":"path","name":"user","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/PubClient"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"Get user clients"}},"/broadcast/otr/messages":{"post":{"description":" [internal route ID: \"post-otr-broadcast-unqualified\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts two query params:\n- `ignore_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are ignored.\n - When 'false' all missing clients are reported.\n - When comma separated list of user-ids, only clients for listed users are ignored.\n- `report_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are reported.\n - When 'false' all missing clients are ignored.\n - When comma separated list of user-ids, only clients for listed users are reported.\n\nApart from these, the request body also accepts `report_missing` which can only be a list of user ids and behaves the same way as the query parameter.\n\nAll three of these should be considered mutually exclusive. The server however does not error if more than one is specified, it reads them in this order of precedence:\n- `report_missing` in the request body has highest precedence.\n- `ignore_missing` in the query param is the next.\n- `report_missing` in the query param has the lowest precedence.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-otr-broadcast-unqualified","parameters":[{"in":"query","name":"ignore_missing","required":false,"schema":{"type":"string"}},{"in":"query","name":"report_missing","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}},"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Message sent"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"too-many-users-to-broadcast","message":"Too many users to fan out the broadcast event to"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["too-many-users-to-broadcast"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `report_missing` or `ignore_missing`\n\nToo many users to fan out the broadcast event to (label: `too-many-users-to-broadcast`)"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","non-binding-team","no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nNot a member of a binding team (label: `non-binding-team`)\n\nTeam not found (label: `no-team`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Missing clients"}},"summary":"Broadcast an encrypted message to all team members and all contacts (accepts JSON or Protobuf)"}},"/broadcast/proteus/messages":{"post":{"description":" [internal route ID: \"post-proteus-broadcast\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts `client_mismatch_strategy` in the body. It can have these values:\n- `report_all`: When set, the message is not sent if any clients are missing. The missing clients are reported in the response.\n- `ignore_all`: When set, no checks about missing clients are carried out.\n- `report_only`: Takes a list of qualified UserIDs. If any clients of the listed users are missing, the message is not sent. The missing clients are reported in the response.\n- `ignore_only`: Takes a list of qualified UserIDs. If any clients of the non-listed users are missing, the message is not sent. The missing clients are reported in the response.\n\nThe sending of messages in a federated conversation could theoretically fail partially. To make this case unlikely, the backend first gets a list of clients from all the involved backends and then tries to send a message. So, if any backend is down, the message is not propagated to anyone. But the actual message fan out to multiple backends could still fail partially. This type of failure is reported as a 201, the clients for which the message sending failed are part of the response body.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-proteus-broadcast","requestBody":{"content":{"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/QualifiedNewOtrMessage"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Message sent"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"too-many-users-to-broadcast","message":"Too many users to fan out the broadcast event to"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["too-many-users-to-broadcast"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nToo many users to fan out the broadcast event to (label: `too-many-users-to-broadcast`)"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","non-binding-team","no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nNot a member of a binding team (label: `non-binding-team`)\n\nTeam not found (label: `no-team`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Missing clients"}},"summary":"Post an encrypted message to all team members and all contacts (accepts only Protobuf)"}},"/calls/config/v2":{"get":{"description":" [internal route ID: \"get-calls-config-v2\"]\n\n","operationId":"get-calls-config-v2","parameters":[{"description":"Limit resulting list. Allowed values [1..10]","in":"query","name":"limit","required":false,"schema":{"maximum":10,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RTCConfiguration_LTIwOTc4OTk0"}}},"description":""}},"summary":"Retrieve all TURN server addresses and credentials. Clients are expected to do a DNS lookup to resolve the IP addresses of the given hostnames "}},"/clients":{"get":{"description":" [internal route ID: \"list-clients\"]\n\n","operationId":"list-clients","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"},"type":"array"}}},"description":"List of clients"}},"summary":"List the registered clients"},"post":{"description":" [internal route ID: \"add-client\"]\n\n","operationId":"add-client","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewClient_ODg1NjY4Njgy"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}}},"description":"Client registered","headers":{"Location":{"description":"Client ID","schema":{"type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"bad-request","message":"Malformed prekeys uploaded"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["bad-request"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMalformed prekeys uploaded (label: `bad-request`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed","missing-auth","too-many-clients"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nRe-authentication via password required (label: `missing-auth`)\n\nToo many clients (label: `too-many-clients`)"}},"summary":"Register a new client"}},"/clients/{cid}/access-token":{"post":{"description":" [internal route ID: \"create-access-token\"]\n\nCreate an JWT DPoP access token for the client CSR, given a JWT DPoP proof, specified in the `DPoP` header. The access token will be returned in the JSON response body as a JWT DPoP token.","operationId":"create-access-token","parameters":[{"description":"ClientId","in":"path","name":"cid","required":true,"schema":{"type":"string"}},{"in":"header","name":"DPoP","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DPoPAccessTokenResponse_LTgyODU5MDE3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DPoPAccessTokenResponse_LTgyODU5MDE3"}}},"description":"Access token created","headers":{"Cache-Control":{"schema":{"type":"string"}}}}},"summary":"Create a JWT DPoP access token"}},"/clients/{client}":{"delete":{"description":" [internal route ID: \"delete-client\"]\n\n","operationId":"delete-client","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RmClient_MTQ5OTI2MDY3"}}},"required":true},"responses":{"200":{"description":"Client deleted"}},"summary":"Delete an existing client"},"get":{"description":" [internal route ID: \"get-client\"]\n\n","operationId":"get-client","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}}},"description":"Client found"},"404":{"description":"`client` or Client not found(**Note**: This error has an empty body for legacy reasons)"}},"summary":"Get a registered client by ID"},"put":{"description":" [internal route ID: \"update-client\"]\n\n","operationId":"update-client","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateClient_NzU5MjA4MzI1"}}},"required":true},"responses":{"200":{"description":"Client updated"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-duplicate-public-key","message":"MLS public key for the given signature scheme already exists"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-duplicate-public-key","bad-request"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMLS public key for the given signature scheme already exists (label: `mls-duplicate-public-key`)\n\nMalformed prekeys uploaded (label: `bad-request`)"}},"summary":"Update a registered client"}},"/clients/{client}/capabilities":{"get":{"description":" [internal route ID: \"get-client-capabilities\"]\n\n","operationId":"get-client-capabilities","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientCapabilityList"}}},"description":""}},"summary":"Read back what the client has been posting about itself"}},"/clients/{client}/nonce":{"get":{"description":" [internal route ID: \"get-nonce\"]\n\nGet a new nonce for a client CSR, specified in the response header `Replay-Nonce` as a uuidv4 in base64url encoding.","operationId":"get-nonce","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content","headers":{"Cache-Control":{"schema":{"type":"string"}},"Replay-Nonce":{"schema":{"type":"string"}}}}},"summary":"Get a new nonce for a client CSR"},"head":{"description":" [internal route ID: \"head-nonce\"]\n\nGet a new nonce for a client CSR, specified in the response header `Replay-Nonce` as a uuidv4 in base64url encoding.","operationId":"head-nonce","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"No Content","headers":{"Cache-Control":{"schema":{"type":"string"}},"Replay-Nonce":{"schema":{"type":"string"}}}}},"summary":"Get a new nonce for a client CSR"}},"/clients/{client}/prekeys":{"get":{"description":" [internal route ID: \"get-client-prekeys\"]\n\n","operationId":"get-client-prekeys","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"maximum":65535,"minimum":0,"type":"integer"},"type":"array"}}},"description":""}},"summary":"List the remaining prekey IDs of a client"}},"/connections/{uid_domain}/{uid}":{"get":{"description":" [internal route ID: \"get-connection\"]\n\n","operationId":"get-connection","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection found"},"404":{"description":"`uid_domain` or `uid` or Connection not found(**Note**: This error has an empty body for legacy reasons)"}},"summary":"Get an existing connection to another user (local or remote)"},"post":{"description":" [internal route ID: \"create-connection\"]\n\nYou can have no more than 1000 connections in accepted or sent state","operationId":"create-connection","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection existed"},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection was created"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-user","message":"Invalid user"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-user"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid user (label: `invalid-user`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-identity","message":"The user has no verified email"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-identity","connection-limit","missing-legalhold-consent","missing-legalhold-consent-old-clients"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The user has no verified email (label: `no-identity`)\n\nToo many sent/accepted connections (label: `connection-limit`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)"}},"summary":"Create a connection to another user"},"put":{"description":" [internal route ID: \"update-connection\"]\n\n","operationId":"update-connection","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConnectionUpdate_LTU3MTA1OTA5"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection updated"},"204":{"description":"Connection unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-user","message":"Invalid user"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-user"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid user (label: `invalid-user`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-identity","message":"The user has no verified email"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-identity","bad-conn-update","not-connected","connection-limit","missing-legalhold-consent","missing-legalhold-consent-old-clients"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The user has no verified email (label: `no-identity`)\n\nInvalid status transition (label: `bad-conn-update`)\n\nUsers are not connected (label: `not-connected`)\n\nToo many sent/accepted connections (label: `connection-limit`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)"}},"summary":"Update a connection to another user"}},"/conversations":{"post":{"description":" [internal route ID: \"create-group-conversation\"]\n\nThis returns 201 when a new conversation is created, and 200 when the conversation already existed","operationId":"create-group-conversation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewConv_LTgzNTk1NDQx"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGroupConversation_LTE2NzQxMDI0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateGroupConversation_LTE2NzQxMDI0"}}},"description":"Conversation created","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"history-not-supported","message":"Shared history is not supported on this conversation"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["history-not-supported","mls-not-enabled","non-empty-member-list"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nShared history is not supported on this conversation (label: `history-not-supported`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nAttempting to add group members outside MLS (label: `non-empty-member-list`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"channels-not-enabled","message":"The channels feature is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["channels-not-enabled","not-mls-conversation","missing-legalhold-consent","operation-denied","no-team-member","not-connected","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The channels feature is not enabled for this team (label: `channels-not-enabled`)\n\nThis operation requires an MLS conversation (label: `not-mls-conversation`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nUsers are not connected (label: `not-connected`)\n\nConversation access denied (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Create a new conversation"}},"/conversations/code-check":{"post":{"description":" [internal route ID: \"code-check\"]\n\nIf the guest links team feature is disabled, this will fail with 404 CodeNotFound.Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/join` which responds with 409 GuestLinksDisabled if guest links are disabled.","operationId":"code-check","parameters":[{"in":"header","name":"X-Forwarded-For","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCode_Mjg3OTI1NTMx"}}},"required":true},"responses":{"200":{"description":"Valid"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-conversation-password","message":"Invalid conversation password"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-conversation-password"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid conversation password (label: `invalid-conversation-password`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"}},"summary":"Check validity of a conversation code."}},"/conversations/join":{"get":{"description":" [internal route ID: \"get-conversation-by-reusable-code\"]\n\n","operationId":"get-conversation-by-reusable-code","parameters":[{"in":"query","name":"key","required":true,"schema":{"type":"string"}},{"in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCoverView_LTMwNDkxMTA1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","access-denied","invalid-conversation-password"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid conversation password (label: `invalid-conversation-password`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"guest-links-disabled","message":"The guest link feature is disabled and all guest links have been revoked"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}},"summary":"Get limited conversation information by key/code pair"},"post":{"description":" [internal route ID: \"join-conversation-by-code-unqualified\"]\n\nIf the guest links team feature is disabled, this will fail with 409 GuestLinksDisabled.Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/code-check` which responds with 404 CodeNotFound if guest links are disabled.","operationId":"join-conversation-by-code-unqualified","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/JoinConversationByCode_NjgzMzM4Mjg5"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation joined"},"204":{"description":"Conversation unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"too-many-members","message":"Maximum number of members per conversation reached"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["too-many-members","no-team-member","invalid-op","access-denied","invalid-conversation-password"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Maximum number of members per conversation reached (label: `too-many-members`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid conversation password (label: `invalid-conversation-password`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"guest-links-disabled","message":"The guest link feature is disabled and all guest links have been revoked"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}},"summary":"Join a conversation using a reusable code"}},"/conversations/list":{"post":{"description":" [internal route ID: \"list-conversations\"]\n\n","operationId":"list-conversations","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ListConversations_MjkxMTIwODMz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationsResponse_NzgwNjAxNjQz"}}},"description":""}},"summary":"Get conversation metadata for a list of conversation ids"}},"/conversations/list-ids":{"post":{"description":" [internal route ID: \"list-conversation-ids\"]\n\nThe IDs returned by this endpoint are paginated. To get the first page, make a call with the `paging_state` field set to `null` (or omitted). Whenever the `has_more` field of the response is set to `true`, more results are available, and they can be obtained by calling the endpoint again, but this time passing the value of `paging_state` returned by the previous call. One can continue in this fashion until all results are returned, which is indicated by `has_more` being `false`. Note that `paging_state` should be considered an opaque token. It should not be inspected, or stored, or reused across multiple unrelated invocations of the endpoint.","operationId":"list-conversation-ids","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetMuliTabPgRqs_ConvrIdLcOm10_ODU0NjQ1NDMz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MultiTabePg_ConvrsIdqf_cLORm_Q_Mzg0MjYzNzM0"}}},"description":""}},"summary":"Get all conversation IDs."}},"/conversations/mls-self":{"get":{"description":" [internal route ID: \"get-mls-self-conversation\"]\n\n","operationId":"get-mls-self-conversation","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v9_OwnConversation_NDU4NDc3MDgz"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v9_OwnConversation_NDU4NDc3MDgz"}}},"description":"The MLS self-conversation"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"}},"summary":"Get the user's MLS self-conversation"}},"/conversations/self":{"post":{"description":" [internal route ID: \"create-self-conversation\"]\n\n","operationId":"create-self-conversation","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_NDU4NDc3MDgzV6"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_NDU4NDc3MDgzV6"}}},"description":"Conversation existed","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_NDU4NDc3MDgzV6"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_NDU4NDc3MDgzV6"}}},"description":"Conversation created","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}}},"summary":"Create a self-conversation"}},"/conversations/{cnv_domain}/{cnv}":{"get":{"description":" [internal route ID: \"get-conversation\"]\n\n","operationId":"get-conversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Conversation_LTU5NTc0NTI2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get a conversation by ID"}},"/conversations/{cnv_domain}/{cnv}/access":{"put":{"description":" [internal route ID: \"update-conversation-access\"]\n\n","operationId":"update-conversation-access","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationAccessData_MjMxMTI5ODc3"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Access updated"},"204":{"description":"Access unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid target access"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid target access (label: `invalid-op`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nInsufficient authorization (missing modify_conversation_access) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update access modes for a conversation"}},"/conversations/{cnv_domain}/{cnv}/add-permission":{"put":{"description":" [internal route ID: \"update-channel-add-permission\"]\n\n","operationId":"update-channel-add-permission","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AddPermissionUpdate_LTU3MzEwOTY4"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Add permissions updated"},"204":{"description":"Add permissions unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid target access"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","not-connected","operation-denied","no-team-member","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid target access (label: `invalid-op`)\n\nUsers are not connected (label: `not-connected`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_add_permissions) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nTeam not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Update the permissions for adding members to a channel"}},"/conversations/{cnv_domain}/{cnv}/groupinfo":{"get":{"description":" [internal route ID: \"get-group-info\"]\n\n","operationId":"get-group-info","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/GroupInfoData"}}},"description":"The group information"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-missing-group-info","message":"The conversation has no group information"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-missing-group-info","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nThe conversation has no group information (label: `mls-missing-group-info`)\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get MLS group information"}},"/conversations/{cnv_domain}/{cnv}/history":{"put":{"description":" [internal route ID: \"update-conversation-history\"]\n\n","operationId":"update-conversation-history","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationHistoryUpdate_LTg5MDQ5Nzgx"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"History updated"},"204":{"description":"History unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"history-not-supported","message":"Shared history is not supported on this conversation"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["history-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nShared history is not supported on this conversation (label: `history-not-supported`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"action-denied","message":"Insufficient authorization (missing modify_conversation_access)"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["action-denied","invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient authorization (missing modify_conversation_access) (label: `action-denied`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update history settings of a conversation"}},"/conversations/{cnv_domain}/{cnv}/members":{"post":{"description":" [internal route ID: \"add-members-to-conversation\"]\n\n","operationId":"add-members-to-conversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InviteQualified_ODYyODIyNjYz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation updated"},"204":{"description":"Conversation unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-group-id-not-supported","message":"The group ID version of the conversation is not supported by one of the federated backends"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-group-id-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","not-connected","no-team-member","access-denied","too-many-members","invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nMaximum number of members per conversation reached (label: `too-many-members`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInsufficient authorization (missing add_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Add qualified members to an existing conversation."},"put":{"description":" [internal route ID: \"replace-members-in-conversation\"]\n\nThis will add any members not already in the conversation, and remove any members not in the provided list except users that are associated via a user group. The given role in the request body will be applied to all added members. The roles of already existing members will not be changed even if these members are included in the request body and their role differs from the role provided in this request.","operationId":"replace-members-in-conversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InviteQualified_ODYyODIyNjYz"}}},"required":true},"responses":{"200":{"description":"Conversation members replaced"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-group-id-not-supported","message":"The group ID version of the conversation is not supported by one of the federated backends"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-group-id-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","not-connected","no-team-member","access-denied","too-many-members","invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nMaximum number of members per conversation reached (label: `too-many-members`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nInsufficient authorization (missing add_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Replace the members of a conversation."}},"/conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}":{"delete":{"description":" [internal route ID: \"remove-member\"]\n\n","operationId":"remove-member","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"usr_domain","required":true,"schema":{"type":"string"}},{"description":"Target User ID","in":"path","name":"usr","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Member removed"},"204":{"description":"No change"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `usr_domain` or `usr` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Remove a member from a conversation"},"put":{"description":" [internal route ID: \"update-other-member\"]\n\n**Note**: at least one field has to be provided.","operationId":"update-other-member","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"usr_domain","required":true,"schema":{"type":"string"}},{"description":"Target User ID","in":"path","name":"usr","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OtherMemberUpdate_LTM1MjYzOTU0"}}},"required":true},"responses":{"200":{"description":"Membership updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nInvalid target (label: `invalid-op`)\n\nInsufficient authorization (missing modify_other_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation-member","message":"Conversation member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation-member","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `usr_domain` or `usr` not found\n\nConversation member not found (label: `no-conversation-member`)\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update membership of the specified user"}},"/conversations/{cnv_domain}/{cnv}/message-timer":{"put":{"description":" [internal route ID: \"update-conversation-message-timer\"]\n\n","operationId":"update-conversation-message-timer","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationMessageTimerUpdate_LTcxMjUwNzQ4"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Message timer updated"},"204":{"description":"Message timer unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_message_timer) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update the message timer for a conversation"}},"/conversations/{cnv_domain}/{cnv}/name":{"put":{"description":" [internal route ID: \"update-conversation-name\"]\n\n","operationId":"update-conversation-name","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationRename_ODkwODg1MzQ0"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Name unchanged"},"204":{"description":"Name updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_conversation_name) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update conversation name"}},"/conversations/{cnv_domain}/{cnv}/proteus/messages":{"post":{"description":" [internal route ID: \"post-proteus-message\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts `client_mismatch_strategy` in the body. It can have these values:\n- `report_all`: When set, the message is not sent if any clients are missing. The missing clients are reported in the response.\n- `ignore_all`: When set, no checks about missing clients are carried out.\n- `report_only`: Takes a list of qualified UserIDs. If any clients of the listed users are missing, the message is not sent. The missing clients are reported in the response.\n- `ignore_only`: Takes a list of qualified UserIDs. If any clients of the non-listed users are missing, the message is not sent. The missing clients are reported in the response.\n\nThe sending of messages in a federated conversation could theoretically fail partially. To make this case unlikely, the backend first gets a list of clients from all the involved backends and then tries to send a message. So, if any backend is down, the message is not propagated to anyone. But the actual message fan out to multiple backends could still fail partially. This type of failure is reported as a 201, the clients for which the message sending failed are part of the response body.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-proteus-message","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/QualifiedNewOtrMessage"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Message sent"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or Conversation not found (label: `no-conversation`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Missing clients"}},"summary":"Post an encrypted message to a conversation (accepts only Protobuf)"}},"/conversations/{cnv_domain}/{cnv}/protocol":{"put":{"description":" [internal route ID: \"update-conversation-protocol\"]\n\n**Note**: Only proteus->mixed upgrade is supported.","operationId":"update-conversation-protocol","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ProtocolUpdate_NzY1ODgxNDQy"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation updated"},"204":{"description":"Conversation unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-migration-criteria-not-satisfied","message":"The migration criteria for mixed to MLS protocol transition are not satisfied for this conversation"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-migration-criteria-not-satisfied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nThe migration criteria for mixed to MLS protocol transition are not satisfied for this conversation (label: `mls-migration-criteria-not-satisfied`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","invalid-op","action-denied","invalid-protocol-transition"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nProtocol transition is invalid (label: `invalid-protocol-transition`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nTeam not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update the protocol of the conversation"}},"/conversations/{cnv_domain}/{cnv}/receipt-mode":{"put":{"description":" [internal route ID: \"update-conversation-receipt-mode\"]\n\n","operationId":"update-conversation-receipt-mode","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationReceiptModeUpdate_NDE4MzUzNTU3"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Receipt mode updated"},"204":{"description":"Receipt mode unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-receipts-not-allowed","message":"Read receipts on MLS conversations are not allowed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-receipts-not-allowed","invalid-op","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Read receipts on MLS conversations are not allowed (label: `mls-receipts-not-allowed`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_receipt_mode) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update receipt mode for a conversation"}},"/conversations/{cnv_domain}/{cnv}/self":{"get":{"description":" [internal route ID: \"get-conversation-self\"]\n\n","operationId":"get-conversation-self","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Member_OTA5OTgyNzcw"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get self membership properties"},"put":{"description":" [internal route ID: \"update-conversation-self\"]\n\n**Note**: at least one field has to be provided.","operationId":"update-conversation-self","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MemberUpdate_LTg4NTQ0OTYz"}}},"required":true},"responses":{"200":{"description":"Update successful"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update self membership properties"}},"/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}":{"delete":{"description":" [internal route ID: \"delete-subconversation\"]\n\n","operationId":"delete-subconversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSReset_NzgwODA3ODc4"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"Deletion successful"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-stale-message","message":"The conversation epoch in a message is too old"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-stale-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The conversation epoch in a message is too old (label: `mls-stale-message`)"}},"summary":"Delete an MLS subconversation"},"get":{"description":" [internal route ID: \"get-subconversation\"]\n\n","operationId":"get-subconversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicSubConversation_MjI2NTIxMzU4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PublicSubConversation_MjI2NTIxMzU4"}}},"description":"Subconversation"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-subconv-unsupported-convtype","message":"MLS subconversations are only supported for regular conversations"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-subconv-unsupported-convtype","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS subconversations are only supported for regular conversations (label: `mls-subconv-unsupported-convtype`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get information about an MLS subconversation"}},"/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}/groupinfo":{"get":{"description":" [internal route ID: \"get-subconversation-group-info\"]\n\n","operationId":"get-subconversation-group-info","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/GroupInfoData"}}},"description":"The group information"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-missing-group-info","message":"The conversation has no group information"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-missing-group-info","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nThe conversation has no group information (label: `mls-missing-group-info`)\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get MLS group information of subconversation"}},"/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}/self":{"delete":{"description":" [internal route ID: \"leave-subconversation\"]\n\n","operationId":"leave-subconversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled","mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nMLS protocol error (label: `mls-protocol-error`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-stale-message","message":"The conversation epoch in a message is too old"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-stale-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The conversation epoch in a message is too old (label: `mls-stale-message`)"}},"summary":"Leave an MLS subconversation"}},"/conversations/{cnv_domain}/{cnv}/typing":{"post":{"description":" [internal route ID: \"member-typing-qualified\"]\n\n","operationId":"member-typing-qualified","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TypingStatus_LTg5MzcyNDMy"}}},"required":true},"responses":{"200":{"description":"Notification sent"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Sending typing notifications"}},"/conversations/{cnv}/code":{"delete":{"description":" [internal route ID: \"remove-code-unqualified\"]\n\n","operationId":"remove-code-unqualified","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation code deleted."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Delete conversation code"},"get":{"description":" [internal route ID: \"get-code\"]\n\n","operationId":"get-code","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}}},"description":"Conversation Code"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"guest-links-disabled","message":"The guest link feature is disabled and all guest links have been revoked"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}},"summary":"Get existing conversation code"},"post":{"description":" [internal route ID: \"create-conversation-code-unqualified\"]\n\n\nOAuth scope: `write:conversations_code`","operationId":"create-conversation-code-unqualified","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateConversationCodeRequest_NTYzMTA1NDYz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}}},"description":"Conversation code already exists."},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation code created."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"create-conv-code-conflict","message":"Conversation code already exists with a different password setting than the requested one."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["create-conv-code-conflict","guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation code already exists with a different password setting than the requested one. (label: `create-conv-code-conflict`)\n\nThe guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}},"summary":"Create or recreate a conversation code"}},"/conversations/{cnv}/features/conversationGuestLinks":{"get":{"description":" [internal route ID: \"get-conversation-guest-links-status\"]\n\n","operationId":"get-conversation-guest-links-status","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get the status of the guest links feature for a conversation that potentially has been created by someone from another team."}},"/conversations/{cnv}/otr/messages":{"post":{"description":" [internal route ID: \"post-otr-message-unqualified\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts two query params:\n- `ignore_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are ignored.\n - When 'false' all missing clients are reported.\n - When comma separated list of user-ids, only clients for listed users are ignored.\n- `report_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are reported.\n - When 'false' all missing clients are ignored.\n - When comma separated list of user-ids, only clients for listed users are reported.\n\nApart from these, the request body also accepts `report_missing` which can only be a list of user ids and behaves the same way as the query parameter.\n\nAll three of these should be considered mutually exclusive. The server however does not error if more than one is specified, it reads them in this order of precedence:\n- `report_missing` in the request body has highest precedence.\n- `ignore_missing` in the query param is the next.\n- `report_missing` in the query param has the lowest precedence.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-otr-message-unqualified","parameters":[{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"ignore_missing","required":false,"schema":{"type":"string"}},{"in":"query","name":"report_missing","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}},"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Message sent"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` or Conversation not found (label: `no-conversation`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Missing clients"}},"summary":"Post an encrypted message to a conversation (accepts JSON or Protobuf)"}},"/conversations/{cnv}/roles":{"get":{"description":" [internal route ID: \"get-conversation-roles\"]\n\n","operationId":"get-conversation-roles","parameters":[{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationRolesList"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get existing roles available for the given conversation"}},"/cookies":{"get":{"description":" [internal route ID: \"list-cookies\"]\n\n","operationId":"list-cookies","parameters":[{"description":"Filter by label (comma-separated list)","in":"query","name":"labels","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CookieList_LTM4MzYwNzAz"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CookieList_LTM4MzYwNzAz"}}},"description":"List of cookies"}},"summary":"Retrieve the list of cookies currently stored for the user"}},"/cookies/remove":{"post":{"description":" [internal route ID: \"remove-cookies\"]\n\n","operationId":"remove-cookies","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RemoveCookies_OTYwMTI0NDMy"}}},"required":true},"responses":{"200":{"description":"Cookies revoked"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)"}},"summary":"Revoke stored cookies"}},"/custom-backend/by-domain/{domain}":{"get":{"description":" [internal route ID: \"get-custom-backend-by-domain\"]\n\n","operationId":"get-custom-backend-by-domain","parameters":[{"description":"URL-encoded email domain","in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CustomBackend_LTQxODI0MjQ0"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"custom-backend-not-found","message":"Custom backend not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["custom-backend-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` not found\n\nCustom backend not found (label: `custom-backend-not-found`)"}},"summary":"Shows information about custom backends related to a given email domain"}},"/delete":{"post":{"description":" [internal route ID: \"verify-delete\"]\n\n","operationId":"verify-delete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/VerifyDeleteUser_Njc1NDQ1MDIy"}}},"required":true},"responses":{"200":{"description":"Deletion is initiated."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-code","message":"Invalid verification code"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid verification code (label: `invalid-code`)"}},"summary":"Verify account deletion with a code."}},"/domain-verification/{domain}/authorize-team":{"post":{"description":" [internal route ID: \"domain-verification-authorize-team\"]\n\n","operationId":"domain-verification-authorize-team","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainOwnershipToken_NTU0ODc1NDE5"}}},"required":true},"responses":{"200":{"description":"Authorized"},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}},"summary":"Authorize a team to operate on a verified domain"}},"/domain-verification/{domain}/backend":{"post":{"description":" [internal route ID: \"update-domain-redirect\"]\n\n","operationId":"update-domain-redirect","parameters":[{"in":"header","name":"Authorization","required":true,"schema":{"type":"string"}},{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainRedirectConfig_NTI5NDE5MDQy"}}},"required":true},"responses":{"200":{"description":"Updated"},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}},"summary":"Update the domain redirect configuration"}},"/domain-verification/{domain}/challenges":{"post":{"description":" [internal route ID: \"domain-verification-challenge\"]\n\n","operationId":"domain-verification-challenge","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainVerificationChallenge_NjIwMzA1MjE5"}}},"description":""}},"summary":"Get a DNS verification challenge"}},"/domain-verification/{domain}/challenges/{challengeId}":{"post":{"description":" [internal route ID: \"verify-challenge\"]\n\n","operationId":"verify-challenge","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"challengeId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ChallengeToken_Mzk3NTcwOTM3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainOwnershipToken_NTU0ODc1NDE5"}}},"description":""},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"domain-verification-failed","message":"Domain verification failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["domain-verification-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain verification failed (label: `domain-verification-failed`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"challenge-not-found","message":"Challenge not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["challenge-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `challengeId` not found\n\nChallenge not found (label: `challenge-not-found`)"}},"summary":"Verify a DNS verification challenge"}},"/domain-verification/{domain}/team":{"post":{"description":" [internal route ID: \"update-team-invite\"]\n\n","operationId":"update-team-invite","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamInviteConfig_MTg4Nzk4NzMz"}}},"required":true},"responses":{"200":{"description":"Updated"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}},"summary":"Update the team-invite configuration"}},"/domain-verification/{domain}/team/challenges/{challengeId}":{"post":{"description":" [internal route ID: \"verify-challenge-team\"]\n\n","operationId":"verify-challenge-team","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"challengeId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ChallengeToken_Mzk3NTcwOTM3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainOwnershipToken_NTU0ODc1NDE5"}}},"description":""},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}},"summary":"Verify a DNS verification challenge for a team"}},"/events":{"get":{"description":" [internal route ID: \"consume-events\"]\n\nThis is the rabbitMQ-based variant of \"await-notifications\"","externalDocs":{"description":"RFC 6455","url":"https://datatracker.ietf.org/doc/html/rfc6455"},"operationId":"consume-events","parameters":[{"description":"Client ID","in":"query","name":"client","required":false,"schema":{"type":"string"}},{"description":"Synchronization marker ID","in":"query","name":"sync_marker","required":false,"schema":{"type":"string"}}],"responses":{"101":{"description":"Connection upgraded."},"426":{"description":"Upgrade required."}},"summary":"Consume events over a websocket connection"}},"/feature-configs":{"get":{"description":" [internal route ID: \"get-all-feature-configs-for-user\"]\n\nGets feature configs for a user. If the user is a member of a team and has the required permissions, this will return the team's feature configs.If the user is not a member of a team, this will return the personal feature configs (the server defaults).\nOAuth scope: `read:feature_configs`","operationId":"get-all-feature-configs-for-user","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NP_LockableFtur_ghdCnfiSOVsyAvIRqExmDpB_MGwUT2Q_LTM1Mzc0ODgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)"}},"summary":"Gets feature configs for a user"}},"/get-domain-registration":{"post":{"description":" [internal route ID: \"get-domain-registration\"]\n\n- `due_to_existing_account`: boolean (optional, only present if `domain_redirect` is `no-registration`)\n- `backend`: object (optional, must be present if `domain_redirect` is `backend`)\n - `config_url`: string (required)\n - `webapp_url`: string (optional)\n- `sso_code`: string (optional, must be present if `domain_redirect` is `sso`)","operationId":"get-domain-registration","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetDomainRegistrationRequest_LTg4NTM1MzM2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainRedirectResponse_V10_LTEyMjI4NTM0"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-domain","message":"Invalid domain"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-domain"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid domain (label: `invalid-domain`)"}},"summary":"Get domain registration configuration by email"}},"/handles":{"post":{"description":" [internal route ID: \"check-user-handles\"]\n\n","operationId":"check-user-handles","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CheckHandles_LTc0OTkxMzAx"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Handle"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Handle"},"type":"array"}}},"description":"List of free handles"}},"summary":"Check availability of user handles"}},"/handles/{handle}":{"head":{"description":" [internal route ID: \"check-user-handle\"]\n\n","operationId":"check-user-handle","parameters":[{"in":"path","name":"handle","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"Handle is taken"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-handle","message":"The given handle is invalid (less than 2 or more than 256 characters; chars not in \"a-z0-9_.-\"; or on the blocklist)"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-handle"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given handle is invalid (less than 2 or more than 256 characters; chars not in \"a-z0-9_.-\"; or on the blocklist) (label: `invalid-handle`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Handle not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`handle` not found\n\nHandle not found (label: `not-found`)"}},"summary":"Check whether a user handle can be taken"}},"/identity-providers":{"get":{"description":" [internal route ID: \"idp-get-all\"]\n\n","operationId":"idp-get-all","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPList"}}},"description":""}}},"post":{"description":" [internal route ID: \"idp-create\"]\n\nCreate a new identity provider.\n\nThe `api_version` parameter controls the uniqueness constraint for IdP issuers:\n- `v1`: IdP issuers must be globally unique across the entire backend (all teams)\n- `v2` (default): IdP issuers must be unique per team (can be reused across different teams)\n\nThese constraints apply to both, multi-ingress and standard backends.","operationId":"idp-create","parameters":[{"in":"query","name":"replaces","required":false,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"api_version","required":false,"schema":{"default":"v2","enum":["v1","v2"],"type":"string"}},{"in":"query","name":"handle","required":false,"schema":{"maxLength":32,"minLength":1,"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}},"application/xml":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}}},"required":true},"responses":{"201":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"}}},"description":""}}}},"/identity-providers/{id}":{"delete":{"description":" [internal route ID: \"idp-delete\"]\n\n","operationId":"idp-delete","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"purge","required":false,"schema":{"type":"boolean"}}],"responses":{"204":{"description":""}}},"get":{"description":" [internal route ID: \"idp-get\"]\n\n","operationId":"idp-get","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"}}},"description":""}}},"put":{"description":" [internal route ID: \"idp-update\"]\n\n","operationId":"idp-update","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"handle","required":false,"schema":{"maxLength":32,"minLength":1,"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}},"application/xml":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"}}},"description":""}}}},"/identity-providers/{id}/raw":{"get":{"description":" [internal route ID: \"idp-get-raw\"]\n\n","operationId":"idp-get-raw","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/xml":{"schema":{"type":"string"}}},"description":""}}}},"/list-connections":{"post":{"description":" [internal route ID: \"list-connections\"]\n\nThe IDs returned by this endpoint are paginated. To get the first page, make a call with the `paging_state` field set to `null` (or omitted). Whenever the `has_more` field of the response is set to `true`, more results are available, and they can be obtained by calling the endpoint again, but this time passing the value of `paging_state` returned by the previous call. One can continue in this fashion until all results are returned, which is indicated by `has_more` being `false`. Note that `paging_state` should be considered an opaque token. It should not be inspected, or stored, or reused across multiple unrelated invocations of the endpoint.","operationId":"list-connections","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetMuliTabPgRqs_ConcLOrm501_LTIwMjA4NzYw"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MultiTabePg_ConcsLOrRmU_MTgwMzU4OTU5"}}},"description":""}},"summary":"List the connections to other users, including remote users"}},"/list-users":{"post":{"description":" [internal route ID: \"list-users-by-ids-or-handles\"]\n\nThe 'qualified_ids' and 'qualified_handles' parameters are mutually exclusive.","operationId":"list-users-by-ids-or-handles","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ListUsersQuery"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ListUsersById_LTQ5MTE3NDc0"}}},"description":""}},"summary":"List users"}},"/login":{"post":{"description":" [internal route ID: \"login\"]\n\nLogins are throttled at the server's discretion","operationId":"login","parameters":[{"description":"Request a persistent cookie instead of a session cookie","in":"query","name":"persist","required":false,"schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Login_LTgyNTIzMTM1"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed","pending-activation","suspended","invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nAccount pending activation (label: `pending-activation`)\n\nAccount suspended (label: `suspended`)\n\nAuthentication failed (label: `invalid-credentials`)"}},"summary":"Authenticate a user to obtain a cookie and first access token"}},"/meetings":{"post":{"description":" [internal route ID: \"create-meeting\"]\n\n","operationId":"create-meeting","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewMeeting_LTI1NTMzOTU5"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"}}},"description":"Meeting created"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Create a new meeting"}},"/meetings/list":{"get":{"description":" [internal route ID: \"list-meetings\"]\n\n","operationId":"list-meetings","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"},"type":"array"}}},"description":""}},"summary":"List all meetings for the authenticated user"}},"/meetings/{domain}/{id}":{"delete":{"description":" [internal route ID: \"delete-meeting\"]\n\n","operationId":"delete-meeting","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"Meeting deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"You do not have permission to access this resource"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"You do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}},"summary":"Delete a meeting"},"get":{"description":" [internal route ID: \"get-meeting\"]\n\n","operationId":"get-meeting","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}},"summary":"Get a single meeting by ID"},"put":{"description":" [internal route ID: \"update-meeting\"]\n\n","operationId":"update-meeting","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateMeeting_NTExNzYxMTcz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"}}},"description":"Meeting updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}},"summary":"Update an existing meeting"}},"/meetings/{domain}/{id}/invitations":{"post":{"description":" [internal route ID: \"add-meeting-invitation\"]\n\n","operationId":"add-meeting-invitation","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MeetingEmailsInvitation_NzgyNzUzMzcz"}}},"required":true},"responses":{"200":{"description":"Invitation added"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"You do not have permission to access this resource"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"You do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}},"summary":"Add an email to the invited emails"}},"/meetings/{domain}/{id}/invitations/delete":{"post":{"description":" [internal route ID: \"remove-meeting-invitation\"]\n\n","operationId":"remove-meeting-invitation","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MeetingEmailsInvitation_NzgyNzUzMzcz"}}},"required":true},"responses":{"200":{"description":"Invitations removed"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"You do not have permission to access this resource"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"You do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}},"summary":"Remove emails from the invited emails"}},"/mls/commit-bundles":{"post":{"description":" [internal route ID: \"mls-commit-bundle\"]\n\n\n\n**Note**: this endpoint can execute proposals, and therefore return all possible errors associated with adding or removing members to a conversation, in addition to the ones listed below. See the documentation of [POST /conversations/{cnv}/members/v2](#/default/post_conversations__cnv__members_v2) and [POST /conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}](#/default/delete_conversations__cnv_domain___cnv__members__usr_domain___usr_) for more details on the possible error responses of each type of proposal.","operationId":"mls-commit-bundle","requestBody":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/CommitBundle"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}}},"description":"Commit accepted and forwarded"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-invalid-leaf-node-signature","message":"Invalid leaf node signature"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-invalid-leaf-node-signature","mls-group-id-not-supported","mls-welcome-mismatch","mls-self-removal-not-allowed","mls-protocol-error","mls-not-enabled","mls-invalid-leaf-node-index","mls-group-conversation-mismatch","mls-commit-missing-references","mls-client-sender-user-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid leaf node signature (label: `mls-invalid-leaf-node-signature`)\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)\n\nSubmitted group info is inconsistent with the backend group state (label `inconsistent-group-state`)\n\nThe list of targets of a welcome message does not match the list of new clients in a group (label: `mls-welcome-mismatch`)\n\nSelf removal from group is not allowed (label: `mls-self-removal-not-allowed`)\n\nMLS protocol error (label: `mls-protocol-error`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nA referenced leaf node index points to a blank or non-existing node (label: `mls-invalid-leaf-node-index`)\n\nConversation ID resolved from Group ID does not match submitted Conversation ID (label: `mls-group-conversation-mismatch`)\n\nThe commit is not referencing all pending proposals (label: `mls-commit-missing-references`)\n\nUser ID resolved from Client ID does not match message's sender user ID (label: `mls-client-sender-user-mismatch`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-identity-mismatch","message":"Leaf node signature key does not match the client's key"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-identity-mismatch","mls-subconv-join-parent-missing","missing-legalhold-consent","legalhold-not-enabled","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Leaf node signature key does not match the client's key (label: `mls-identity-mismatch`)\n\nMLS client cannot join the subconversation because it is not member of the parent conversation (label: `mls-subconv-join-parent-missing`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-proposal-not-found","message":"A proposal referenced in a commit message could not be found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-proposal-not-found","no-conversation","no-conversation-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"A proposal referenced in a commit message could not be found (label: `mls-proposal-not-found`)\n\nConversation not found (label: `no-conversation`)\n\nConversation member not found (label: `no-conversation-member`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"missing_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["missing_users"],"type":"object"}}},"description":"Group is out of sync\n\nAdding members to the conversation is not possible because the backends involved do not form a fully connected graph\n\nA user who is under legal-hold may not participate in MLS conversations (label: `mls-legal-hold-not-allowed`)\n\nThe conversation epoch in a message is too old (label: `mls-stale-message`)\n\nA proposal of type Add or Remove does not apply to the full list of clients for a user (label: `mls-client-mismatch`)"},"422":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":422,"label":"mls-unsupported-proposal","message":"Unsupported proposal type"},"properties":{"code":{"enum":[422],"type":"integer"},"label":{"enum":["mls-unsupported-proposal","mls-unsupported-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unsupported proposal type (label: `mls-unsupported-proposal`)\n\nAttempted to send a message with an unsupported combination of content type and wire format (label: `mls-unsupported-message`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Post a MLS CommitBundle"}},"/mls/key-packages/claim/{user_domain}/{user}":{"post":{"description":" [internal route ID: \"mls-key-packages-claim\"]\n\nOnly key packages for the specified ciphersuite are claimed.","operationId":"mls-key-packages-claim","parameters":[{"in":"path","name":"user_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"user","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Ciphersuite in hex format (e.g. 0x0002)","in":"query","name":"ciphersuite","required":true,"schema":{"type":"number"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyPackageBundle_MjU2MjY0MDU2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageBundle_MjU2MjY0MDU2"}}},"description":"Claimed key packages"}},"summary":"Claim one key package for each client of the given user"}},"/mls/key-packages/self/{client}":{"delete":{"description":" [internal route ID: \"mls-key-packages-delete\"]\n\n","operationId":"mls-key-packages-delete","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Ciphersuite in hex format (e.g. 0x0002)","in":"query","name":"ciphersuite","required":true,"schema":{"type":"number"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteKeyPackages_LTQxNTcxNjY3"}}},"required":true},"responses":{"201":{"description":"OK"}},"summary":"Delete all key packages for a given ciphersuite and client"},"post":{"description":" [internal route ID: \"mls-key-packages-upload\"]\n\nThe request body should be a json object containing a list of base64-encoded key packages.","operationId":"mls-key-packages-upload","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageUpload_NTQ2Mjk2NzEx"}}},"required":true},"responses":{"201":{"description":"Key packages uploaded"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-protocol-error","message":"MLS protocol error"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMLS protocol error (label: `mls-protocol-error`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-identity-mismatch","message":"Key package credential does not match qualified client ID"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-identity-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Key package credential does not match qualified client ID (label: `mls-identity-mismatch`)"}},"summary":"Upload a fresh batch of key packages"},"put":{"description":" [internal route ID: \"mls-key-packages-replace\"]\n\nThe request body should be a json object containing a list of base64-encoded key packages. Use this sparingly.","operationId":"mls-key-packages-replace","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Comma-separated list of ciphersuites in hex format (e.g. 0x0002)","in":"query","name":"ciphersuites","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageUpload_NTQ2Mjk2NzEx"}}},"required":true},"responses":{"201":{"description":"Key packages replaced"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-protocol-error","message":"MLS protocol error"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `ciphersuites`\n\nMLS protocol error (label: `mls-protocol-error`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-identity-mismatch","message":"Key package credential does not match qualified client ID"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-identity-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Key package credential does not match qualified client ID (label: `mls-identity-mismatch`)"}},"summary":"Upload a fresh batch of key packages and replace the old ones"}},"/mls/key-packages/self/{client}/count":{"get":{"description":" [internal route ID: \"mls-key-packages-count\"]\n\n","operationId":"mls-key-packages-count","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Ciphersuite in hex format (e.g. 0x0002)","in":"query","name":"ciphersuite","required":true,"schema":{"type":"number"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyPackageCount_LTYwNDg5MDcz"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageCount_LTYwNDg5MDcz"}}},"description":"Number of key packages"}},"summary":"Return the number of unclaimed key packages for a given ciphersuite and client"}},"/mls/messages":{"post":{"description":" [internal route ID: \"mls-message\"]\n\n\n\n**Note**: this endpoint can execute proposals, and therefore return all possible errors associated with adding or removing members to a conversation, in addition to the ones listed below. See the documentation of [POST /conversations/{cnv}/members/v2](#/default/post_conversations__cnv__members_v2) and [POST /conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}](#/default/delete_conversations__cnv_domain___cnv__members__usr_domain___usr_) for more details on the possible error responses of each type of proposal.","operationId":"mls-message","requestBody":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/MLSMessage"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}}},"description":"Message sent"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-invalid-leaf-node-signature","message":"Invalid leaf node signature"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-invalid-leaf-node-signature","mls-self-removal-not-allowed","mls-protocol-error","mls-not-enabled","mls-invalid-leaf-node-index","mls-group-conversation-mismatch","mls-commit-missing-references","mls-client-sender-user-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid leaf node signature (label: `mls-invalid-leaf-node-signature`)\n\nSubmitted group info is inconsistent with the backend group state (label `inconsistent-group-state`)\n\nSelf removal from group is not allowed (label: `mls-self-removal-not-allowed`)\n\nMLS protocol error (label: `mls-protocol-error`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nA referenced leaf node index points to a blank or non-existing node (label: `mls-invalid-leaf-node-index`)\n\nConversation ID resolved from Group ID does not match submitted Conversation ID (label: `mls-group-conversation-mismatch`)\n\nThe commit is not referencing all pending proposals (label: `mls-commit-missing-references`)\n\nUser ID resolved from Client ID does not match message's sender user ID (label: `mls-client-sender-user-mismatch`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-subconv-join-parent-missing","message":"MLS client cannot join the subconversation because it is not member of the parent conversation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-subconv-join-parent-missing","missing-legalhold-consent","legalhold-not-enabled","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS client cannot join the subconversation because it is not member of the parent conversation (label: `mls-subconv-join-parent-missing`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-proposal-not-found","message":"A proposal referenced in a commit message could not be found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-proposal-not-found","no-conversation","no-conversation-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"A proposal referenced in a commit message could not be found (label: `mls-proposal-not-found`)\n\nConversation not found (label: `no-conversation`)\n\nConversation member not found (label: `no-conversation-member`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"missing_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["missing_users"],"type":"object"}}},"description":"Group is out of sync\n\nAdding members to the conversation is not possible because the backends involved do not form a fully connected graph\n\nThe conversation epoch in a message is too old (label: `mls-stale-message`)\n\nA proposal of type Add or Remove does not apply to the full list of clients for a user (label: `mls-client-mismatch`)"},"422":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":422,"label":"mls-unsupported-proposal","message":"Unsupported proposal type"},"properties":{"code":{"enum":[422],"type":"integer"},"label":{"enum":["mls-unsupported-proposal","mls-unsupported-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unsupported proposal type (label: `mls-unsupported-proposal`)\n\nAttempted to send a message with an unsupported combination of content type and wire format (label: `mls-unsupported-message`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Post an MLS message"}},"/mls/public-keys":{"get":{"description":" [internal route ID: \"mls-public-keys\"]\n\nThe format of the returned key is determined by the `format` query parameter:\n - raw (default): base64-encoded raw public keys\n - jwk: keys are nested objects in JWK format.","operationId":"mls-public-keys","parameters":[{"in":"query","name":"format","required":false,"schema":{"enum":["raw","jwk"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx"}}},"description":"Public keys"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `format`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"}},"summary":"Get public keys used by the backend to sign external proposals"}},"/mls/reset-conversation":{"post":{"description":" [internal route ID: \"mls-reset-conversation\"]\n\n","operationId":"mls-reset-conversation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSReset_NzgwODA3ODc4"}}},"required":true},"responses":{"200":{"description":"Conversation reset"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-protocol-error","message":"MLS protocol error"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-protocol-error","mls-group-id-not-supported","mls-federated-reset-not-supported","mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS protocol error (label: `mls-protocol-error`)\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)\n\nReset is not supported by the owning backend of the conversation (label: `mls-federated-reset-not-supported`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`) or `body`"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"action-denied","message":"Insufficient authorization (missing leave_conversation)"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["action-denied","invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-stale-message","message":"The conversation epoch in a message is too old"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-stale-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The conversation epoch in a message is too old (label: `mls-stale-message`)"}},"summary":"Reset an MLS conversation to epoch 0"}},"/notifications":{"get":{"description":" [internal route ID: \"get-notifications\"]\n\nSee also: GET /teams/notifications","operationId":"get-notifications","parameters":[{"description":"Only return notifications more recent than this","in":"query","name":"since","required":false,"schema":{"format":"uuid","type":"string"}},{"description":"Only return notifications targeted at the given client","in":"query","name":"client","required":false,"schema":{"type":"string"}},{"description":"Maximum number of notifications to return","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":10000,"minimum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueuedNotificationList_MTU0ODEyNTQ2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotificationList_MTU0ODEyNTQ2"}}},"description":"Notification list"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Some notifications not found (label: `not-found`)"}},"summary":"Fetch notifications"}},"/notifications/last":{"get":{"description":" [internal route ID: \"get-last-notification\"]\n\n","operationId":"get-last-notification","parameters":[{"description":"Only return notifications targeted at the given client","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}}},"description":"Notification found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Some notifications not found (label: `not-found`)"}},"summary":"Fetch the last notification"}},"/notifications/{id}":{"get":{"description":" [internal route ID: \"get-notification-by-id\"]\n\n","operationId":"get-notification-by-id","parameters":[{"description":"Notification ID","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Only return notifications targeted at the given client","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}}},"description":"Notification found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`id` or Some notifications not found (label: `not-found`)"}},"summary":"Fetch a notification by ID"}},"/oauth/applications":{"get":{"description":" [internal route ID: \"get-oauth-applications\"]\n\nGet all OAuth applications with active account access for a user.","operationId":"get-oauth-applications","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/OAuthApplication_Mjk5NTUxNjA1"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/OAuthApplication_Mjk5NTUxNjA1"},"type":"array"}}},"description":"OAuth applications found"}},"summary":"Get OAuth applications with account access"}},"/oauth/applications/{OAuthClientId}/sessions":{"delete":{"description":" [internal route ID: \"revoke-oauth-account-access\"]\n\n","operationId":"revoke-oauth-account-access","parameters":[{"description":"The ID of the OAuth client","in":"path","name":"OAuthClientId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordReqBody_LTcxMzE3ODE3"}}},"required":true},"responses":{"204":{"description":"OAuth application access revoked"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"Revoke account access from an OAuth application"}},"/oauth/applications/{OAuthClientId}/sessions/{RefreshTokenId}":{"delete":{"description":" [internal route ID: \"delete-oauth-refresh-token\"]\n\nRevoke an active OAuth session by providing the refresh token ID.","operationId":"delete-oauth-refresh-token","parameters":[{"description":"The ID of the OAuth client","in":"path","name":"OAuthClientId","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"The ID of the refresh token","in":"path","name":"RefreshTokenId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordReqBody_LTcxMzE3ODE3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`OAuthClientId` or `RefreshTokenId` not found\n\nOAuth client not found (label: `not-found`)"}},"summary":"Revoke an active OAuth session"}},"/oauth/authorization/codes":{"post":{"description":" [internal route ID: \"create-oauth-auth-code\"]\n\nCurrently only supports the 'code' response type, which corresponds to the authorization code flow.","operationId":"create-oauth-auth-code","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateOAuthAuthorizationCodeRequest_LTQ3NzI1Mjkz"}}},"required":true},"responses":{"201":{"description":"Created","headers":{"Location":{"schema":{"type":"string"}}}},"400":{"content":{"application/json":{"schema":{"example":{"code":400,"label":"redirect-url-miss-match","message":"The redirect URL does not match the one registered with the client"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["redirect-url-miss-match"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"redirect-url-miss-match","message":"The redirect URL does not match the one registered with the client"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["redirect-url-miss-match"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Bad Request\n\nThe redirect URL does not match the one registered with the client (label: `redirect-url-miss-match`) or `body`","headers":{"Location":{"schema":{"type":"string"}}}},"403":{"description":"Forbidden","headers":{"Location":{"schema":{"type":"string"}}}},"404":{"description":"Not Found","headers":{"Location":{"schema":{"type":"string"}}}}},"summary":"Create an OAuth authorization code"}},"/oauth/clients/{OAuthClientId}":{"get":{"description":" [internal route ID: \"get-oauth-client\"]\n\n","operationId":"get-oauth-client","parameters":[{"description":"The ID of the OAuth client","in":"path","name":"OAuthClientId","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthClient_NzExMTI5NTIy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OAuthClient_NzExMTI5NTIy"}}},"description":"OAuth client found"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"forbidden","message":"OAuth is disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"OAuth is disabled (label: `forbidden`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`OAuthClientId` or OAuth client not found (label: `not-found`)\n\nOAuth client not found (label: `not-found`)"}},"summary":"Get OAuth client information"}},"/oauth/revoke":{"post":{"description":" [internal route ID: \"revoke-oauth-refresh-token\"]\n\nRevoke an access token.","operationId":"revoke-oauth-refresh-token","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OAuthRevokeRefreshTokenRequest_MjA1MDg4MzQ4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"forbidden","message":"Invalid refresh token"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid refresh token (label: `forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"OAuth client not found (label: `not-found`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"jwt-error","message":"Internal error while handling JWT token"},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["jwt-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Internal error while handling JWT token (label: `jwt-error`)"}},"summary":"Revoke an OAuth refresh token"}},"/oauth/token":{"post":{"description":" [internal route ID: \"create-oauth-access-token\"]\n\nObtain a new access token from an authorization code or a refresh token.","operationId":"create-oauth-access-token","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Either_OAuthAccessTokenRequest_OAuthRefreshAccessTokenRequest"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OAuthAccessTokenResponse_NzEwOTI4NjQ0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid_grant","message":"Invalid grant"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid_grant","forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid grant (label: `invalid_grant`)\n\nInvalid client credentials (label: `forbidden`)\n\nInvalid grant type (label: `forbidden`)\n\nInvalid refresh token (label: `forbidden`)\n\nOAuth is disabled (label: `forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"OAuth client not found (label: `not-found`)\n\nOAuth authorization code not found (label: `not-found`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"jwt-error","message":"Internal error while handling JWT token"},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["jwt-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Internal error while handling JWT token (label: `jwt-error`)"}},"summary":"Create an OAuth access token"}},"/one2one-conversations":{"post":{"description":" [internal route ID: \"create-one-to-one-conversation\"]\n\n","operationId":"create-one-to-one-conversation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOne2OneConv_LTI3OTc4NDAz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_NDU4NDc3MDgzV3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_NDU4NDc3MDgzV3"}}},"description":"Conversation existed","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_NDU4NDc3MDgzV3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_NDU4NDc3MDgzV3"}}},"description":"Conversation created","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","operation-denied","not-connected","no-team-member","non-binding-team-members","invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nInsufficient permissions (label: `operation-denied`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nBoth users must be members of the same binding team (label: `non-binding-team-members`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","non-binding-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)\n\nNot a member of a binding team (label: `non-binding-team`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Create a 1:1 conversation"}},"/one2one-conversations/{usr_domain}/{usr}":{"get":{"description":" [internal route ID: \"get-one-to-one-mls-conversation\"]\n\n","operationId":"get-one-to-one-mls-conversation","parameters":[{"in":"path","name":"usr_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"usr","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"format","required":false,"schema":{"enum":["raw","jwk"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSOne2OneConversation_SomeKey_LTMxNjE2NjUy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSOne2OneConversation_SomeKey_LTMxNjE2NjUy"}}},"description":"MLS 1-1 conversation"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `format`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"not-connected","message":"Users are not connected"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["not-connected"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Users are not connected (label: `not-connected`)"}},"summary":"Get an MLS 1:1 conversation"}},"/password-reset":{"post":{"description":" [internal route ID: \"post-password-reset\"]\n\n","operationId":"post-password-reset","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewPasswordReset_LTEyNzAxMTcy"}}},"required":true},"responses":{"201":{"description":"Password reset code created and sent by email."}},"summary":"Initiate a password reset."}},"/password-reset/complete":{"post":{"description":" [internal route ID: \"post-password-reset-complete\"]\n\n","operationId":"post-password-reset-complete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CompletePasswordReset_NDcyMjY5OTc4"}}},"required":true},"responses":{"200":{"description":"Password reset successful."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-code","message":"Invalid password reset code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)"}},"summary":"Complete a password reset."}},"/properties":{"delete":{"description":" [internal route ID: \"clear-properties\"]\n\n","operationId":"clear-properties","responses":{"200":{"description":"Properties cleared"}},"summary":"Clear all properties"},"get":{"description":" [internal route ID: \"list-property-keys\"]\n\n","operationId":"list-property-keys","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ASCII"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/ASCII"},"type":"array"}}},"description":"List of property keys"}},"summary":"List all property keys"}},"/properties-values":{"get":{"description":" [internal route ID: \"list-properties\"]\n\n","operationId":"list-properties","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PropertyKeysAndValues"}}},"description":""}},"summary":"List all properties with key and value"}},"/properties/{key}":{"delete":{"description":" [internal route ID: \"delete-property\"]\n\n","operationId":"delete-property","parameters":[{"in":"path","name":"key","required":true,"schema":{"format":"printable","type":"string"}}],"responses":{"200":{"description":"Property deleted"}},"summary":"Delete a property"},"get":{"description":" [internal route ID: \"get-property\"]\n\n","operationId":"get-property","parameters":[{"in":"path","name":"key","required":true,"schema":{"format":"printable","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertyValue"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PropertyValue"}}},"description":"The property value"},"404":{"description":"`key` or Property not found(**Note**: This error has an empty body for legacy reasons)"}},"summary":"Get a property value"},"put":{"description":" [internal route ID: \"set-property\"]\n\n","operationId":"set-property","parameters":[{"in":"path","name":"key","required":true,"schema":{"format":"printable","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PropertyValue"}}},"required":true},"responses":{"200":{"description":"Property set"}},"summary":"Set a user property"}},"/provider":{"delete":{"description":" [internal route ID: \"provider-delete\"]\n\n","operationId":"provider-delete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteProvider_MzYxMzM3Mjg2"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","invalid-provider","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nThe provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Delete a provider"},"get":{"description":" [internal route ID: \"provider-get-account\"]\n\n","operationId":"provider-get-account","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Provider not found. (label: `not-found`)\n\nProvider not found. (label: `not-found`)"}},"summary":"Get account"},"put":{"description":" [internal route ID: \"provider-update\"]\n\n","operationId":"provider-update","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateProvider_LTQwMjY4MDgy"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-provider","message":"The provider does not exist."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-provider","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Update a provider"}},"/provider/activate":{"get":{"description":" [internal route ID: \"provider-activate\"]\n\n","operationId":"provider-activate","parameters":[{"in":"query","name":"key","required":true,"schema":{"type":"string"}},{"in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderActivationResponse_LTgzNTU3MzA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ProviderActivationResponse_LTgzNTU3MzA5"}}},"description":""},"204":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-code","message":"Invalid verification code"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-code","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid verification code (label: `invalid-code`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Activate a provider"}},"/provider/assets":{"post":{"description":" [internal route ID: (\"assets-upload-v3\", provider)]\n\n

Construct the request as multipart/mixed; set header Content-Type: multipart/mixed; boundary=<boundary>.

Use exactly two parts in this order:

  1. application/json metadata (AssetSettings)
  2. application/octet-stream asset bytes

Each part must include Content-Type and Content-Length; the second part may include Content-MD5. Use CRLF between headers and bodies.

When asset audit logging is enabled, the JSON metadata must include:

  • convId: object { id: UUID, domain: String } (qualified conversation ID)
  • filename: String
  • filetype: String MIME type (e.g. image/png, application/pdf)

Optional metadata: public (Bool, default false), retention (one of eternal, persistent, volatile, eternal-infrequent_access, expiring).

For profile pictures or team icons without a conversation, set convId.id to 00000000-0000-0000-0000-000000000000 and convId.domain to the tenant’s domain; use any reasonable filename.

Note: the server treats the asset bytes as application/octet-stream; filetype is used for auditing only.

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

--frontier
Content-Type: application/json
Content-Length: 191

{\"public\":false,\"retention\":\"volatile\",\"convId\":{\"id\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"domain\":\"example.com\"},\"filename\":\"report.pdf\",\"filetype\":\"application/pdf\"}
--frontier
Content-Type: application/octet-stream
Content-Length: 11

Hello Audit
--frontier--
","operationId":"assets-upload-v3_provider","requestBody":{"content":{"multipart/mixed":{"schema":{"$ref":"#/components/schemas/AssetSource"}}},"description":"A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server."},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}}},"description":"Asset posted","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"incomplete-body","message":"HTTP content-length header does not match body size"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["incomplete-body","invalid-length"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nHTTP content-length header does not match body size (label: `incomplete-body`)\n\nInvalid content length (label: `invalid-length`)"},"413":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"client-error","message":"Asset too large"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["client-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Asset too large (label: `client-error`)"}},"summary":"Upload an asset"}},"/provider/assets/{key}":{"delete":{"description":" [internal route ID: (\"assets-delete-v3\", provider)]\n\n","operationId":"assets-delete-v3_provider","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` not found\n\nAsset not found (label: `not-found`)"}},"summary":"Delete an asset"},"get":{"description":" [internal route ID: (\"assets-download-v3\", provider)]\n\n","operationId":"assets-download-v3_provider","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}},{"in":"header","name":"Asset-Token","required":false,"schema":{"type":"string"}},{"in":"query","name":"asset_token","required":false,"schema":{"type":"string"}}],"responses":{"302":{"description":"Asset found","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` or Asset not found (label: `not-found`)"}},"summary":"Download an asset"}},"/provider/email":{"put":{"description":" [internal route ID: \"provider-update-email\"]\n\n","operationId":"provider-update-email","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/EmailUpdate_LTYwODE0ODQ5"}}},"required":true},"responses":{"202":{"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-provider","message":"The provider does not exist."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-provider","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Too many request to generate a verification code."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many request to generate a verification code. (label: `too-many-requests`)"}},"summary":"Update a provider email"}},"/provider/login":{"post":{"description":" [internal route ID: \"provider-login\"]\n\n","operationId":"provider-login","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ProviderLogin_LTE2MTk2NTM5"}}},"required":true},"responses":{"200":{"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Login as a provider"}},"/provider/password":{"put":{"description":" [internal route ID: \"provider-update-password\"]\n\n","operationId":"provider-update-password","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordChange_NDI0ODgwNDU0"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password reset, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password reset, new and old password must be different. (label: `password-must-differ`)"}},"summary":"Update a provider password"}},"/provider/password-reset":{"post":{"description":" [internal route ID: \"provider-password-reset\"]\n\n","operationId":"provider-password-reset","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordReset_LTYzNDYxNTQ3"}}},"required":true},"responses":{"201":{"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-code","message":"Invalid password reset code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-code","invalid-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)\n\nInvalid email or mobile number for password reset. (label: `invalid-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password reset, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ","code-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password reset, new and old password must be different. (label: `password-must-differ`)\n\nA password reset is already in progress. (label: `code-exists`)\n\nA password reset is already in progress. (label: `code-exists`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Too many request to generate a verification code."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many request to generate a verification code. (label: `too-many-requests`)"}},"summary":"Begin a password reset"}},"/provider/password-reset/complete":{"post":{"description":" [internal route ID: \"provider-password-reset-complete\"]\n\n","operationId":"provider-password-reset-complete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CompletePasswordReset_LTYzMDAxNDA1"}}},"required":true},"responses":{"200":{"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-code","message":"Invalid password reset code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","invalid-code","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nInvalid verification code (label: `invalid-code`)\n\nAccess denied. (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password reset, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password reset, new and old password must be different. (label: `password-must-differ`)"}},"summary":"Complete a password reset"}},"/provider/register":{"post":{"description":" [internal route ID: \"provider-register\"]\n\n","operationId":"provider-register","parameters":[{"in":"header","name":"X-Forwarded-For","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewProvider_LTEyMTY5MjYy"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewProviderResponse_OTE0ODI2NjU0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewProviderResponse_OTE0ODI2NjU0"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `X-Forwarded-For`\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Too many request to generate a verification code."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many request to generate a verification code. (label: `too-many-requests`)"}},"summary":"Register a new provider"}},"/provider/services":{"get":{"description":" [internal route ID: \"get-provider-services\"]\n\n","operationId":"get-provider-services","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Service_MjcyOTA5NjQx"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"List provider services"},"post":{"description":" [internal route ID: \"post-provider-services\"]\n\n","operationId":"post-provider-services","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewService_LTYwOTU1MDQ3"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewServiceResponse_LTExMzcwMjg5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewServiceResponse_LTExMzcwMjg5"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-service-key","message":"Invalid service key."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-service-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid service key. (label: `invalid-service-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"Create a new service"}},"/provider/services/{service-id}":{"delete":{"description":" [internal route ID: \"delete-provider-services-by-service-id\"]\n\n","operationId":"delete-provider-services-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteService_LTY2NzY5NzMz"}}},"required":true},"responses":{"202":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nService not found. (label: `not-found`)"}},"summary":"Delete service"},"get":{"description":" [internal route ID: \"get-provider-services-by-service-id\"]\n\n","operationId":"get-provider-services-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Service_MjcyOTA5NjQx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nService not found. (label: `not-found`)"}},"summary":"Get provider service by service id"},"put":{"description":" [internal route ID: \"put-provider-services-by-service-id\"]\n\n","operationId":"put-provider-services-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateService_MjAxNzQ2Njkz"}}},"required":true},"responses":{"200":{"description":"Provider service updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nProvider not found. (label: `not-found`)\n\nService not found. (label: `not-found`)"}},"summary":"Update provider service"}},"/provider/services/{service-id}/connection":{"put":{"description":" [internal route ID: \"put-provider-services-connection-by-service-id\"]\n\n","operationId":"put-provider-services-connection-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateServiceConn_LTQ1OTYwNjIz"}}},"required":true},"responses":{"200":{"description":"Provider service connection updated"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-service-key","message":"Invalid service key."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-service-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid service key. (label: `invalid-service-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nService not found. (label: `not-found`)"}},"summary":"Update provider service connection"}},"/providers/{pid}":{"get":{"description":" [internal route ID: \"provider-get-profile\"]\n\n","operationId":"provider-get-profile","parameters":[{"in":"path","name":"pid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}}},"description":""},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`pid` or Provider not found. (label: `not-found`)"}},"summary":"Get profile"}},"/providers/{provider-id}/services":{"get":{"description":" [internal route ID: \"get-provider-services-by-provider-id\"]\n\n","operationId":"get-provider-services-by-provider-id","parameters":[{"in":"path","name":"provider-id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/ServiceProfile_LTc2MDQzNTk3"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"Get provider services by provider id"}},"/providers/{provider-id}/services/{service-id}":{"get":{"description":" [internal route ID: \"get-provider-services-by-provider-id-and-service-id\"]\n\n","operationId":"get-provider-services-by-provider-id-and-service-id","parameters":[{"in":"path","name":"provider-id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceProfile_LTc2MDQzNTk3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`provider-id` or `service-id` not found\n\nService not found. (label: `not-found`)"}},"summary":"Get provider service by provider id and service id"}},"/proxy/giphy/v1/gifs":{},"/proxy/googlemaps/api/staticmap":{},"/proxy/googlemaps/maps/api/geocode":{},"/proxy/soundcloud/resolve":{},"/proxy/soundcloud/stream":{},"/proxy/spotify/api/token":{},"/proxy/youtube/v3":{},"/push/tokens":{"get":{"description":" [internal route ID: \"get-push-tokens\"]\n\n","operationId":"get-push-tokens","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PushTokenList_NDI0Mjc3MzY3"}}},"description":""}},"summary":"List the user's registered push tokens"},"post":{"description":" [internal route ID: \"register-push-token\"]\n\n","operationId":"register-push-token","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"}}},"description":"Push token registered","headers":{"Location":{"schema":{"type":"string"}}}},"400":{"content":{"application/json":{"schema":{"example":{"code":400,"label":"apns-voip-not-supported","message":"Adding APNS_VOIP tokens is not supported"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["apns-voip-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"apns-voip-not-supported","message":"Adding APNS_VOIP tokens is not supported"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["apns-voip-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Adding APNS_VOIP tokens is not supported (label: `apns-voip-not-supported`) or `body`"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"app-not-found","message":"App does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["app-not-found","invalid-token"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"app-not-found","message":"App does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["app-not-found","invalid-token"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"App does not exist (label: `app-not-found`)\n\nInvalid push token (label: `invalid-token`)"},"413":{"content":{"application/json":{"schema":{"example":{"code":413,"label":"sns-thread-budget-reached","message":"Too many concurrent calls to SNS; is SNS down?"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["sns-thread-budget-reached","token-too-long","metadata-too-long"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"sns-thread-budget-reached","message":"Too many concurrent calls to SNS; is SNS down?"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["sns-thread-budget-reached","token-too-long","metadata-too-long"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many concurrent calls to SNS; is SNS down? (label: `sns-thread-budget-reached`)\n\nPush token length must be < 8192 for GCM or 400 for APNS (label: `token-too-long`)\n\nTried to add token to endpoint resulting in metadata length > 2048 (label: `metadata-too-long`)"}},"summary":"Register a native push token"}},"/push/tokens/{pid}":{"delete":{"description":" [internal route ID: \"delete-push-token\"]\n\n","operationId":"delete-push-token","parameters":[{"description":"The push token to delete","in":"path","name":"pid","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Push token unregistered"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Push token not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Push token not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`pid` or Push token not found (label: `not-found`)"}},"summary":"Unregister a native push token"}},"/register":{"post":{"description":" [internal route ID: \"register\"]\n\nIf the environment where the registration takes place is private and a registered email address is not whitelisted, a 403 error is returned.","operationId":"register","parameters":[{"in":"header","name":"X-Forwarded-For","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewUser_PlainTextPassword_8_LTI4MzI5NzQx"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User_NjA4OTQwMTQ4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/User_NjA4OTQwMTQ4"}}},"description":"User created and pending activation","headers":{"Location":{"description":"UserId","schema":{"format":"uuid","type":"string"}},"Set-Cookie":{"description":"Cookie","schema":{"type":"string"}}}},"400":{"content":{"application/json":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code","invalid-email","invalid-phone"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code","invalid-email","invalid-phone"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid invitation code. (label: `invalid-invitation-code`)\n\nInvalid e-mail address. (label: `invalid-email`)\n\nInvalid mobile phone number (label: `invalid-phone`) or `body` or `X-Forwarded-For`"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unauthorized","message":"Unauthorized e-mail address"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorized","missing-identity","blacklisted-email","too-many-team-members","user-creation-restricted","ephemeral-user-creation-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorized","message":"Unauthorized e-mail address"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorized","missing-identity","blacklisted-email","too-many-team-members","user-creation-restricted","ephemeral-user-creation-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorized e-mail address (label: `unauthorized`)\n\nUsing an invitation code requires registering the given email. (label: `missing-identity`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nToo many members in this team. (label: `too-many-team-members`)\n\nThis instance does not allow creation of personal users or teams. (label: `user-creation-restricted`)\n\nEphemeral user creation is disabled on this instance. (label: `ephemeral-user-creation-disabled`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"invalid-code","message":"User does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"User does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"User does not exist (label: `invalid-code`)\n\nInvalid activation code (label: `invalid-code`)"},"409":{"content":{"application/json":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}},"summary":"Register a new user."}},"/scim/auth-tokens":{"delete":{"description":" [internal route ID: \"auth-tokens-delete\"]\n\n","operationId":"auth-tokens-delete","parameters":[{"in":"query","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}},"get":{"description":" [internal route ID: \"auth-tokens-list\"]\n\n","operationId":"auth-tokens-list","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ScimTokenList_NjQwNTYxOTAw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}},"post":{"description":" [internal route ID: \"auth-tokens-create\"]\n\n","operationId":"auth-tokens-create","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateScimToken_OTY0NjYxMDQ2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateScimTokenResponse_LTIzOTU2NDU4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}}},"/scim/auth-tokens/{id}":{"put":{"description":" [internal route ID: \"auth-tokens-put-name\"]\n\n","operationId":"auth-tokens-put-name","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ScimTokenName_LTgzOTM2OTI4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}}},"/search/contacts":{"get":{"description":" [internal route ID: \"search-contacts\"]\n\n","operationId":"search-contacts","parameters":[{"description":"Search query

The search query is normalized: lower-cased and diacritics are removed ('Björn' becomes 'bjorn'). The normalized search query matches accounts that, in this order of priority:

  • are equal to the normalized full handle;
  • are equal to the normalized full user display name;
  • prefix-match the normalized handle;
  • prefix-match the normalized user display name.

NB: '@' Does NOT do anything special, ignoring user display names.

See also: [authoritative ElasticSearch query](https://github.com/wireapp/wire-server/blob/83c25cca6a5e9d2205c102410b452eb78fc50a00/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs#L251-L288)

","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Searched domain. Note: This is optional only for backwards compatibility, future versions will mandate this.","in":"query","name":"domain","required":false,"schema":{"type":"string"}},{"description":"Number of results to return (min: 1, max: 500, default 15)","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":500,"minimum":1,"type":"integer"}},{"description":"Only user types. Omitted or empty (type=) means no filtering.","in":"query","name":"type","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SearchResult_Contact_OTExNzg4MTE0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `insufficient-permissions`)"}},"summary":"Search for users"}},"/self":{"delete":{"description":" [internal route ID: \"delete-self\"]\n\nif the account has a verified identity, a verification code is sent and needs to be confirmed to authorise the deletion. if the account has no verified identity but a password, it must be provided. if password is correct, or if neither a verified identity nor a password exists, account deletion is scheduled immediately.","operationId":"delete-self","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteUser_NjE0MjE2Mjkz"}}},"required":true},"responses":{"200":{"description":"Deletion is initiated."},"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionCodeTimeout_LTU1MTk0NDI3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeletionCodeTimeout_LTU1MTk0NDI3"}}},"description":"Deletion is pending verification with a code."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-user","message":"Invalid user"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-user"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid user (label: `invalid-user`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-self-delete-for-team-owner","message":"Team owners are not allowed to delete themselves; ask a fellow owner"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-self-delete-for-team-owner","pending-delete","missing-auth","invalid-credentials","invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team owners are not allowed to delete themselves; ask a fellow owner (label: `no-self-delete-for-team-owner`)\n\nA verification code for account deletion is still pending (label: `pending-delete`)\n\nRe-authentication via password required (label: `missing-auth`)\n\nAuthentication failed (label: `invalid-credentials`)\n\nInvalid verification code (label: `invalid-code`)"}},"summary":"Initiate account deletion."},"get":{"description":" [internal route ID: \"get-self\"]\n\n\nOAuth scope: `read:self`","operationId":"get-self","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/User_NjA4OTQwMTQ4"}}},"description":""}},"summary":"Get your own profile"},"put":{"description":" [internal route ID: \"put-self\"]\n\n","operationId":"put-self","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserUpdate_MjQ4NTEwOTQz"}}},"required":true},"responses":{"200":{"description":"User updated"}},"summary":"Update your profile."}},"/self/email":{"delete":{"description":" [internal route ID: \"remove-email\"]\n\nYour email address can only be removed if you also have a phone number.","operationId":"remove-email","responses":{"200":{"description":"Identity Removed"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"last-identity","message":"The last user identity cannot be removed."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["last-identity","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"last-identity","message":"The last user identity cannot be removed."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["last-identity","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The last user identity cannot be removed. (label: `last-identity`)\n\nThe user has no verified email (label: `no-identity`)"}},"summary":"Remove your email address."}},"/self/handle":{"put":{"description":" [internal route ID: \"change-handle\"]\n\n","operationId":"change-handle","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/HandleUpdate_NTI4NDk1OTAx"}}},"required":true},"responses":{"200":{"description":"Handle Changed"}},"summary":"Change your handle."}},"/self/locale":{"put":{"description":" [internal route ID: \"change-locale\"]\n\n","operationId":"change-locale","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LocaleUpdate_LTgzNjgyOTEw"}}},"required":true},"responses":{"200":{"description":"Local Changed"}},"summary":"Change your locale."}},"/self/password":{"head":{"description":" [internal route ID: \"check-password-exists\"]\n\n","operationId":"check-password-exists","responses":{"200":{"description":"Password is set"},"404":{"description":"Password is not set"}},"summary":"Check that your password is set."},"put":{"description":" [internal route ID: \"change-password\"]\n\n","operationId":"change-password","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordChange_MTgzMDM2NTY2"}}},"required":true},"responses":{"200":{"description":"Password Changed"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nThe user has no verified email (label: `no-identity`)"},"409":{"content":{"application/json":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password change, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password change, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password change, new and old password must be different. (label: `password-must-differ`)"}},"summary":"Change your password."}},"/self/supported-protocols":{"put":{"description":" [internal route ID: \"change-supported-protocols\"]\n\n","operationId":"change-supported-protocols","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SupportedProtocolUpdate_LTE3Njk3MDM4"}}},"required":true},"responses":{"200":{"description":"Supported protocols changed"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-protocol-error","message":"MLS protocol cannot be removed"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS protocol cannot be removed (label: `mls-protocol-error`)"}},"summary":"Change your supported protocols"}},"/services":{"get":{"description":" [internal route ID: \"get-services\"]\n\n","operationId":"get-services","parameters":[{"in":"query","name":"tags","required":false,"schema":{"enum":["audio","books","business","design","education","entertainment","finance","fitness","food-drink","games","graphics","health","integration","lifestyle","media","medical","movies","music","news","photography","poll","productivity","quiz","rating","shopping","social","sports","travel","tutorial","video","weather"],"type":"string"}},{"in":"query","name":"start","required":false,"schema":{"type":"string"}},{"in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":100,"minimum":10,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceProfilePage_Njg1NDQ5Njc4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"List services"}},"/services/tags":{"get":{"description":" [internal route ID: \"get-services-tags\"]\n\n","operationId":"get-services-tags","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceTagList"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"Get services tags"}},"/sso/finalize-login":{"post":{"deprecated":true,"description":" [internal route ID: \"auth-resp-legacy\"]\n\nDEPRECATED! use /sso/metadata/:tid instead! Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams","operationId":"auth-resp-legacy","responses":{"200":{"content":{"text/plain;charset=utf-8":{"schema":{"type":"string"}}},"description":""}}}},"/sso/finalize-login/{team}":{"post":{"description":" [internal route ID: \"auth-resp\"]\n\n","operationId":"auth-resp","parameters":[{"in":"path","name":"team","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/plain;charset=utf-8":{"schema":{"type":"string"}}},"description":""}}}},"/sso/get-by-email":{"post":{"description":" [internal route ID: \"sso-get-by-email\"]\n\n","operationId":"sso-get-by-email","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetByEmailReq_LTY4MzE3Njgy"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}}},"description":"SSO code found"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}}},"description":"SSO code not found or feature disabled"}}}},"/sso/initiate-login/{idp}":{"get":{"description":" [internal route ID: \"auth-req\"]\n\n","operationId":"auth-req","parameters":[{"in":"query","name":"success_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"error_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"label","required":false,"schema":{"type":"string"}},{"in":"path","name":"idp","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/html":{"schema":{"$ref":"#/components/schemas/FormRedirect"}}},"description":""}}},"head":{"description":" [internal route ID: \"auth-req-precheck\"]\n\n","operationId":"auth-req-precheck","parameters":[{"in":"query","name":"success_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"error_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"label","required":false,"schema":{"type":"string"}},{"in":"path","name":"idp","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/plain;charset=utf-8":{}},"description":""}}}},"/sso/metadata":{"get":{"deprecated":true,"description":" [internal route ID: \"sso-metadata\"]\n\nDEPRECATED! use /sso/metadata/:tid instead! Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams","operationId":"sso-metadata","responses":{"200":{"content":{"application/xml":{"schema":{"type":"string"}}},"description":""}}}},"/sso/metadata/{team}":{"get":{"description":" [internal route ID: \"sso-team-metadata\"]\n\n","operationId":"sso-team-metadata","parameters":[{"in":"path","name":"team","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/xml":{"schema":{"type":"string"}}},"description":""}}}},"/sso/settings":{"get":{"description":" [internal route ID: \"sso-settings\"]\n\n","operationId":"sso-settings","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SsoSettings"}}},"description":""}}}},"/system/settings":{"get":{"description":" [internal route ID: \"get-system-settings\"]\n\n","operationId":"get-system-settings","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SystemSettings_ODU3MDk5MTA3"}}},"description":""}},"summary":"Returns a curated set of system configuration settings for authorized users."}},"/system/settings/unauthorized":{"get":{"description":" [internal route ID: \"get-system-settings-unauthorized\"]\n\n","operationId":"get-system-settings-unauthorized","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SystemSettingsPublic_LTgwNTMxNjU2"}}},"description":""}},"summary":"Returns a curated set of system configuration settings."}},"/teams/invitations/accept":{"post":{"description":" [internal route ID: \"accept-team-invitation\"]\n\n","operationId":"accept-team-invitation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AcceptTeamInvitation_Nzg5NzI3MjA2"}}},"required":true},"responses":{"200":{"description":"Team invitation accepted."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-auth","message":"Re-authentication via password required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-auth","invalid-credentials","missing-identity","too-many-team-members"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Re-authentication via password required (label: `missing-auth`)\n\nAuthentication failed (label: `invalid-credentials`)\n\nUsing an invitation code requires registering the given email. (label: `missing-identity`)\n\nToo many members in this team. (label: `too-many-team-members`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"Invalid activation code"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code","not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)\n\nNo pending invitations exists. (label: `not-found`)"}},"summary":"Accept a team invitation, changing a personal account into a team member account."}},"/teams/invitations/by-email":{"head":{"description":" [internal route ID: \"head-team-invitations\"]\n\n","operationId":"head-team-invitations","parameters":[{"description":"Email address","in":"query","name":"email","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Pending invitation exists."},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"No pending invitations exists."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"No pending invitations exists."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"No pending invitations exists. (label: `not-found`)"},"409":{"content":{"application/json":{"schema":{"example":{"code":409,"label":"conflicting-invitations","message":"Multiple conflicting invitations to different teams exists."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["conflicting-invitations"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"conflicting-invitations","message":"Multiple conflicting invitations to different teams exists."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["conflicting-invitations"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Multiple conflicting invitations to different teams exists. (label: `conflicting-invitations`)"}},"summary":"Check if there is an invitation pending given an email address."}},"/teams/invitations/info":{"get":{"description":" [internal route ID: \"get-team-invitation-info\"]\n\n","operationId":"get-team-invitation-info","parameters":[{"description":"Invitation code","in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitationUserView_LTUyMTE3Nzkz"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InvitationUserView_LTUyMTE3Nzkz"}}},"description":"Invitation info"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `code`\n\nInvalid invitation code. (label: `invalid-invitation-code`)"}},"summary":"Get invitation info given a code."}},"/teams/notifications":{"get":{"description":" [internal route ID: \"get-team-notifications\"]\n\n

This is a work-around for scalability issues with gundeck user event fan-out. It does not track all team-wide events, but only `member-join`.

Note that `/teams/notifications` behaves differently from `/notifications`:

  • If there is a gap between the notification id requested with `since` and the available data, team queues respond with 200 and the data that could be found. They do NOT respond with status 404, but valid data in the body.\n
  • The notification with the id given via `since` is included in the response if it exists. You should remove this and only use it to decide whether there was a gap between your last request and this one.\n
  • If the notification id does *not* exist, you get the more recent events from the queue (instead of all of them). This can be done because a notification id is a UUIDv1, which is essentially a time stamp.\n
  • There is no corresponding `/last` end-point to get only the most recent event. That end-point was only useful to avoid having to pull the entire queue. In team queues, if you have never requested the queue before and have no prior notification id, just pull with timestamp 'now'.

See also: GET /notifications

","operationId":"get-team-notifications","parameters":[{"description":"Notification id to start with in the response (UUIDv1)","in":"query","name":"since","required":false,"schema":{"format":"uuid","type":"string"}},{"description":"Maximum number of events to return (1..10000; default: 1000)","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":10000,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotificationList_MTU0ODEyNTQ2"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-notification-id","message":"Could not parse notification id (must be UUIDv1)."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-notification-id"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `size` or `since`\n\nCould not parse notification id (must be UUIDv1). (label: `invalid-notification-id`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)"}},"summary":"Read recently added team members from team queue"}},"/teams/{team-id}/services/whitelist":{"post":{"description":" [internal route ID: \"post-team-whitelist-by-team-id\"]\n\n","operationId":"post-team-whitelist-by-team-id","parameters":[{"in":"path","name":"team-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateServiceWhitelist_LTU5MDAwMTIw"}}},"required":true},"responses":{"200":{"description":"UpdateServiceWhitelistRespChanged"},"204":{"description":"UpdateServiceWhitelistRespUnchanged"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-services-not-allowed","message":"Services not allowed in MLS"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-services-not-allowed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Services not allowed in MLS (label: `mls-services-not-allowed`)"}},"summary":"Update service whitelist"}},"/teams/{team-id}/services/whitelisted":{"get":{"description":" [internal route ID: \"get-whitelisted-services-by-team-id\"]\n\n","operationId":"get-whitelisted-services-by-team-id","parameters":[{"in":"path","name":"team-id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"prefix","required":false,"schema":{"maxLength":128,"minLength":1,"type":"string"}},{"in":"query","name":"filter_disabled","required":false,"schema":{"type":"boolean"}},{"in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":100,"minimum":10,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceProfilePage_Njg1NDQ5Njc4"}}},"description":""}},"summary":"Get whitelisted services by team id"}},"/teams/{teamId}/registered-domains":{"get":{"description":" [internal route ID: \"get-all-registered-domains\"]\n\n","operationId":"get-all-registered-domains","parameters":[{"in":"path","name":"teamId","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RegisteredDomains_V10_NDYwNzYyMTMy"}}},"description":""}},"summary":"Get all registered domains"}},"/teams/{teamId}/registered-domains/{domain}":{"delete":{"description":" [internal route ID: \"delete-registered-domain\"]\n\n","operationId":"delete-registered-domain","parameters":[{"in":"path","name":"teamId","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}},"summary":"Delete a registered domain"}},"/teams/{tid}":{"delete":{"description":" [internal route ID: \"delete-team\"]\n\n","operationId":"delete-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamDeleteData_ODI5NTU0ODE5"}}},"required":true},"responses":{"202":{"description":"Team is scheduled for removal"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Verification code required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed","access-denied","operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Verification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (missing DeleteTeam) (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"503":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":503,"label":"queue-full","message":"The delete queue is full; no further delete requests can be processed at the moment"},"properties":{"code":{"enum":[503],"type":"integer"},"label":{"enum":["queue-full"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The delete queue is full; no further delete requests can be processed at the moment (label: `queue-full`)"}},"summary":"Delete a team"},"get":{"description":" [internal route ID: \"get-team\"]\n\n","operationId":"get-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Team_NDg4MjQwOTIw"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get a team by ID"},"put":{"description":" [internal route ID: \"update-team\"]\n\n","operationId":"update-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamUpdateData_LTE0NTM2NTU5"}}},"required":true},"responses":{"200":{"description":"Team updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions (missing SetTeamData)"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (missing SetTeamData) (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}},"summary":"Update team properties"}},"/teams/{tid}/apps":{"get":{"description":" [internal route ID: \"get-apps\"]\n\n","operationId":"get-apps","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"},"type":"array"}}},"description":""}},"summary":"Get all apps owned by the given team (not including collaborators)"},"post":{"description":" [internal route ID: \"create-app\"]\n\n","operationId":"create-app","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewApp_LTQwODMwMzQ4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreatedApp_LTM3NjUxOTY1"}}},"description":""}},"summary":"Create a new app"}},"/teams/{tid}/apps/{app}":{"put":{"description":" [internal route ID: \"put-app\"]\n\n","operationId":"put-app","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"app","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PutApp_LTE4MDc1OTM4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""}},"summary":"Update metadata of an existing app"}},"/teams/{tid}/apps/{app}/cookies":{"post":{"description":" [internal route ID: \"refresh-app-cookie\"]\n\n","operationId":"refresh-app-cookie","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"app","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RefreshAppCookieRequest_MjEyMDMyMTk5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RefreshAppCookieResponse_LTQ0MjU1NTIw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-auth","message":"Re-authentication via password required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-auth"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Re-authentication via password required (label: `missing-auth`)"}},"summary":"Get a new app authentication token"}},"/teams/{tid}/channels/search":{"get":{"description":" [internal route ID: \"search-channels\"]\n\n","operationId":"search-channels","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Search string","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"in":"query","name":"sort_order","required":false,"schema":{"enum":["asc","desc"],"type":"string"}},{"in":"query","name":"page_size","required":false,"schema":{"description":"integer from [1..500]","type":"number"}},{"description":"`name` of the last seen channel of the current page, used to get the next page.","in":"query","name":"last_seen_name","required":false,"schema":{"type":"string"}},{"description":"`id` of the last seen channel, used to get the next page, used as a tie breaker. **Must** be sent to get the next page.","in":"query","name":"last_seen_id","required":false,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"discoverable","schema":{"default":false,"type":"boolean"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationPage_LTIwMDU2NDI3"}}},"description":""}},"summary":"Search channels"}},"/teams/{tid}/collaborators":{"get":{"description":" [internal route ID: \"get-team-collaborators\"]\n\n","operationId":"get-team-collaborators","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/TeamCollaborator_LTI3MzM1MTYz"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/TeamCollaborator_LTI3MzM1MTYz"},"type":"array"}}},"description":"Return collaborators"}},"summary":"Get all collaborators of the team."},"post":{"description":" [internal route ID: \"add-team-collaborator\"]\n\n","operationId":"add-team-collaborator","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewTeamCollaborator_LTIxNjEzMTYw"}}},"required":true},"responses":{"200":{"description":""}},"summary":"Add a collaborator to the team."}},"/teams/{tid}/collaborators/{uid}":{"delete":{"description":" [internal route ID: \"remove-team-collaborator\"]\n\n","operationId":"remove-team-collaborator","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"}},"summary":"Remove a collaborator from the team."},"put":{"description":" [internal route ID: \"update-team-collaborator\"]\n\n","operationId":"update-team-collaborator","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/CollaboratorPermission_NDg5NTg2ODgy"},"type":"array","uniqueItems":true}}},"required":true},"responses":{"200":{"description":""}},"summary":"Update a collaborator permissions from the team."}},"/teams/{tid}/conversations":{"get":{"description":" [internal route ID: \"get-team-conversations\"]\n\n","operationId":"get-team-conversations","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamConversationList_OTI3MzY3NzY0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"}},"summary":"Get team conversations"}},"/teams/{tid}/conversations/roles":{"get":{"description":" [internal route ID: \"get-team-conversation-roles\"]\n\n","operationId":"get-team-conversation-roles","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationRolesList"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"}},"summary":"Get existing roles available for the given team"}},"/teams/{tid}/conversations/{cid}":{"delete":{"description":" [internal route ID: \"delete-team-conversation\"]\n\n","operationId":"delete-team-conversation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"cid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"Conversation deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing delete_conversation) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `cid` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Remove a team conversation"},"get":{"description":" [internal route ID: \"get-team-conversation\"]\n\n","operationId":"get-team-conversation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"cid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamConversation_LTIwNzgyNTEz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `cid` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get one team conversation"}},"/teams/{tid}/features":{"get":{"description":" [internal route ID: \"get-all-feature-configs-for-team\"]\n\nGets feature configs for a team. User must be a member of the team and have permission to view team features.","operationId":"get-all-feature-configs-for-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NP_LockableFtur_ghdCnfiSOVsyAvIRqExmDpB_MGwUT2Q_LTM1Mzc0ODgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Gets feature configs for a team"}},"/teams/{tid}/features/allowedGlobalOperations":{"get":{"description":" [internal route ID: (\"get\", AllowedGlobalOperationsConfig)]\n\n","operationId":"get_AllowedGlobalOperationsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AllowedGlobalOperationsConfig_NjQ1MjA5MDYw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for allowedGlobalOperations"}},"/teams/{tid}/features/appLock":{"get":{"description":" [internal route ID: (\"get\", AppLockConfigB)]\n\n","operationId":"get_AppLockConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for appLock"},"put":{"description":" [internal route ID: (\"put\", AppLockConfigB)]\n\n","operationId":"put_AppLockConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_AppLockConfigB_Bare_Identity_MTAzMjI5NDYy"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for appLock"}},"/teams/{tid}/features/apps":{"get":{"description":" [internal route ID: (\"get\", AppsConfig)]\n\n","operationId":"get_AppsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AppsConfig_MzQyNTMxNTk5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for apps"}},"/teams/{tid}/features/assetAuditLog":{"get":{"description":" [internal route ID: (\"get\", AssetAuditLogConfig)]\n\n","operationId":"get_AssetAuditLogConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AssetAuditLogConfig_NDQ3MzcyMzk2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for assetAuditLog"}},"/teams/{tid}/features/backgroundEffects":{"get":{"description":" [internal route ID: (\"get\", BackgroundEffectsConfig)]\n\n","operationId":"get_BackgroundEffectsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_BackgroundEffectsConfig_MTg1MTk3NTM5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for backgroundEffects"},"put":{"description":" [internal route ID: (\"put\", BackgroundEffectsConfig)]\n\n","operationId":"put_BackgroundEffectsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_BackgroundEffectsConfig_MjQyOTkxMDc4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_BackgroundEffectsConfig_MTg1MTk3NTM5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for backgroundEffects"}},"/teams/{tid}/features/cells":{"get":{"description":" [internal route ID: (\"get\", CellsConfigB)]\n\n","operationId":"get_CellsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for cells"},"put":{"description":" [internal route ID: (\"put\", CellsConfigB)]\n\n","operationId":"put_CellsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_CellsConfigB_Bare_Identity_NzU2NjgxNzEw"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for cells"}},"/teams/{tid}/features/cellsInternal":{"get":{"description":" [internal route ID: (\"get\", CellsInternalConfigB)]\n\n","operationId":"get_CellsInternalConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_CsInfigBdy_LTQ3MTU3Mjg0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for cellsInternal"}},"/teams/{tid}/features/channels":{"get":{"description":" [internal route ID: (\"get\", ChannelsConfigB)]\n\n","operationId":"get_ChannelsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for channels"},"put":{"description":" [internal route ID: (\"put\", ChannelsConfigB)]\n\n","operationId":"put_ChannelsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_ChannelsConfigB_Bare_Identity_LTU4NTE4MTgx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for channels"}},"/teams/{tid}/features/chatBubbles":{"get":{"description":" [internal route ID: (\"get\", ChatBubblesConfig)]\n\n","operationId":"get_ChatBubblesConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ChatBubblesConfig_NDgwMTQzNjI2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for chatBubbles"}},"/teams/{tid}/features/classifiedDomains":{"get":{"description":" [internal route ID: (\"get\", ClassifiedDomainsConfig)]\n\n","operationId":"get_ClassifiedDomainsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ClassifiedDomainsConfig_LTY1ODQwODg1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for classifiedDomains"}},"/teams/{tid}/features/conferenceCalling":{"get":{"description":" [internal route ID: (\"get\", ConferenceCallingConfigB)]\n\n","operationId":"get_ConferenceCallingConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_CnfigBIdy_NzY1NDU5MDAy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for conferenceCalling"},"put":{"description":" [internal route ID: (\"put\", ConferenceCallingConfigB)]\n\n","operationId":"put_ConferenceCallingConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_ConferenceCallingConfigB_Bare_Identity_Njc2NTcxNTI3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_CnfigBIdy_NzY1NDU5MDAy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for conferenceCalling"}},"/teams/{tid}/features/consumableNotifications":{"get":{"description":" [internal route ID: (\"get\", ConsumableNotificationsConfig)]\n\n","operationId":"get_ConsumableNotificationsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ConsumableNotificationsConfig_MjUxMjczMjM0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for consumableNotifications"}},"/teams/{tid}/features/conversationGuestLinks":{"get":{"description":" [internal route ID: (\"get\", GuestLinksConfig)]\n\n","operationId":"get_GuestLinksConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for conversationGuestLinks"},"put":{"description":" [internal route ID: (\"put\", GuestLinksConfig)]\n\n","operationId":"put_GuestLinksConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_GuestLinksConfig_NjQyMDMxNjg3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for conversationGuestLinks"}},"/teams/{tid}/features/digitalSignatures":{"get":{"description":" [internal route ID: (\"get\", DigitalSignaturesConfig)]\n\n","operationId":"get_DigitalSignaturesConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_DigitalSignaturesConfig_ODM2MDA2NDU4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for digitalSignatures"}},"/teams/{tid}/features/domainRegistration":{"get":{"description":" [internal route ID: (\"get\", DomainRegistrationConfig)]\n\n","operationId":"get_DomainRegistrationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_DomainRegistrationConfig_NzU0NjczNTE0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for domainRegistration"}},"/teams/{tid}/features/enforceFileDownloadLocation":{"get":{"description":" [internal route ID: (\"get\", EnforceFileDownloadLocationConfigB)]\n\n

Custom feature: only supported on some dedicated on-prem systems.

","operationId":"get_EnforceFileDownloadLocationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for enforceFileDownloadLocation"},"put":{"description":" [internal route ID: (\"put\", EnforceFileDownloadLocationConfigB)]\n\n

Custom feature: only supported on some dedicated on-prem systems.

","operationId":"put_EnforceFileDownloadLocationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Featur_EnfocilDwdLCgBIy_MjcxMzc1NDA5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for enforceFileDownloadLocation"}},"/teams/{tid}/features/exposeInvitationURLsToTeamAdmin":{"get":{"description":" [internal route ID: (\"get\", ExposeInvitationURLsToTeamAdminConfig)]\n\n","operationId":"get_ExposeInvitationURLsToTeamAdminConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for exposeInvitationURLsToTeamAdmin"},"put":{"description":" [internal route ID: (\"put\", ExposeInvitationURLsToTeamAdminConfig)]\n\n","operationId":"put_ExposeInvitationURLsToTeamAdminConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_ExposeInvitationURLsToTeamAdminConfig_LTY5NzY2Mzg5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for exposeInvitationURLsToTeamAdmin"}},"/teams/{tid}/features/fileSharing":{"get":{"description":" [internal route ID: (\"get\", FileSharingConfig)]\n\n","operationId":"get_FileSharingConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_FileSharingConfig_MjgwNjIzODEz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for fileSharing"},"put":{"description":" [internal route ID: (\"put\", FileSharingConfig)]\n\n","operationId":"put_FileSharingConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_FileSharingConfig_LTUyNjkxMzM4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_FileSharingConfig_MjgwNjIzODEz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for fileSharing"}},"/teams/{tid}/features/legalhold":{"get":{"description":" [internal route ID: (\"get\", LegalholdConfig)]\n\n","operationId":"get_LegalholdConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_LegalholdConfig_LTc5MTk5OTIw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for legalhold"},"put":{"description":" [internal route ID: (\"put\", LegalholdConfig)]\n\n","operationId":"put_LegalholdConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_LegalholdConfig_NjM3MTkxNjYw"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_LegalholdConfig_LTc5MTk5OTIw"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-disable-unimplemented","message":"legal hold cannot be disabled for whitelisted teams"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-disable-unimplemented","legalhold-not-enabled","too-large-team-for-legalhold","action-denied","no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nCannot enable legalhold on large teams (reason: for removing LH from team, we need to iterate over all members, which is only supported for teams with less than 2k members) (label: `too-large-team-for-legalhold`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}},"summary":"Put config for legalhold"}},"/teams/{tid}/features/limitedEventFanout":{"get":{"description":" [internal route ID: (\"get\", LimitedEventFanoutConfig)]\n\n","operationId":"get_LimitedEventFanoutConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_LimitedEventFanoutConfig_MTg3ODM0NzU0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for limitedEventFanout"}},"/teams/{tid}/features/meetings":{"get":{"description":" [internal route ID: (\"get\", MeetingsConfig)]\n\n","operationId":"get_MeetingsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for meetings"},"put":{"description":" [internal route ID: (\"put\", MeetingsConfig)]\n\n","operationId":"put_MeetingsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MeetingsConfig_NDc2MzM0MDE1"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for meetings"}},"/teams/{tid}/features/meetingsPremium":{"get":{"description":" [internal route ID: (\"get\", MeetingsPremiumConfig)]\n\n","operationId":"get_MeetingsPremiumConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MeetingsPremiumConfig_NDg1ODEyNTA1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for meetingsPremium"},"put":{"description":" [internal route ID: (\"put\", MeetingsPremiumConfig)]\n\n","operationId":"put_MeetingsPremiumConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MeetingsPremiumConfig_NzE4NjUzMDE0"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MeetingsPremiumConfig_NDg1ODEyNTA1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for meetingsPremium"}},"/teams/{tid}/features/mls":{"get":{"description":" [internal route ID: (\"get\", MLSConfigB)]\n\n","operationId":"get_MLSConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for mls"},"put":{"description":" [internal route ID: (\"put\", MLSConfigB)]\n\n","operationId":"put_MLSConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MLSConfigB_Bare_Identity_LTI5MjA3MDYy"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for mls"}},"/teams/{tid}/features/mlsE2EId":{"get":{"description":" [internal route ID: (\"get\", MlsE2EIdConfigB)]\n\n","operationId":"get_MlsE2EIdConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for mlsE2EId"},"put":{"description":" [internal route ID: (\"put\", MlsE2EIdConfigB)]\n\n","operationId":"put_MlsE2EIdConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MlsE2EIdConfigB_Bare_Identity_LTUxODYzODEx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for mlsE2EId"}},"/teams/{tid}/features/mlsMigration":{"get":{"description":" [internal route ID: (\"get\", MlsMigrationConfigB)]\n\n","operationId":"get_MlsMigrationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_MsignCfBIdy_LTE1NjAxNjU2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for mlsMigration"},"put":{"description":" [internal route ID: (\"put\", MlsMigrationConfigB)]\n\n","operationId":"put_MlsMigrationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MlsMigrationConfigB_Bare_Identity_LTQyMDAxMTkz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_MsignCfBIdy_LTE1NjAxNjU2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for mlsMigration"}},"/teams/{tid}/features/outlookCalIntegration":{"get":{"description":" [internal route ID: (\"get\", OutlookCalIntegrationConfig)]\n\n","operationId":"get_OutlookCalIntegrationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for outlookCalIntegration"},"put":{"description":" [internal route ID: (\"put\", OutlookCalIntegrationConfig)]\n\n","operationId":"put_OutlookCalIntegrationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_OutlookCalIntegrationConfig_LTg0MjIxNTMx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for outlookCalIntegration"}},"/teams/{tid}/features/preventAdminlessGroups":{"get":{"description":" [internal route ID: (\"get\", PreventAdminlessGroupsConfigB)]\n\n","operationId":"get_PreventAdminlessGroupsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for preventAdminlessGroups"},"put":{"description":" [internal route ID: (\"put\", PreventAdminlessGroupsConfigB)]\n\n","operationId":"put_PreventAdminlessGroupsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Featur_PvnAdmilsGopCfgBIy_LTE2NzM3ODkx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for preventAdminlessGroups"}},"/teams/{tid}/features/searchVisibility":{"get":{"description":" [internal route ID: (\"get\", SearchVisibilityAvailableConfig)]\n\n","operationId":"get_SearchVisibilityAvailableConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for searchVisibility"},"put":{"description":" [internal route ID: (\"put\", SearchVisibilityAvailableConfig)]\n\n","operationId":"put_SearchVisibilityAvailableConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_SearchVisibilityAvailableConfig_LTMzNTkxODI1"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for searchVisibility"}},"/teams/{tid}/features/searchVisibilityInbound":{"get":{"description":" [internal route ID: (\"get\", SearchVisibilityInboundConfig)]\n\n","operationId":"get_SearchVisibilityInboundConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for searchVisibilityInbound"},"put":{"description":" [internal route ID: (\"put\", SearchVisibilityInboundConfig)]\n\n","operationId":"put_SearchVisibilityInboundConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_SearchVisibilityInboundConfig_MTI1NzQxODY2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for searchVisibilityInbound"}},"/teams/{tid}/features/selfDeletingMessages":{"get":{"description":" [internal route ID: (\"get\", SelfDeletingMessagesConfigB)]\n\n","operationId":"get_SelfDeletingMessagesConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for selfDeletingMessages"},"put":{"description":" [internal route ID: (\"put\", SelfDeletingMessagesConfigB)]\n\n","operationId":"put_SelfDeletingMessagesConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Featur_SlfDingMsCoBIdy_LTg2MzYzNjc2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for selfDeletingMessages"}},"/teams/{tid}/features/simplifiedUserConnectionRequestQRCode":{"get":{"description":" [internal route ID: (\"get\", SimplifiedUserConnectionRequestQRCodeConfig)]\n\n","operationId":"get_SimplifiedUserConnectionRequestQRCodeConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_SimpfdUsCnRqQg_Njk5NjU4OTgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for simplifiedUserConnectionRequestQRCode"}},"/teams/{tid}/features/sndFactorPasswordChallenge":{"get":{"description":" [internal route ID: (\"get\", SndFactorPasswordChallengeConfig)]\n\n","operationId":"get_SndFactorPasswordChallengeConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for sndFactorPasswordChallenge"},"put":{"description":" [internal route ID: (\"put\", SndFactorPasswordChallengeConfig)]\n\n","operationId":"put_SndFactorPasswordChallengeConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_SndFactorPasswordChallengeConfig_NDc0MzUyMzQz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for sndFactorPasswordChallenge"}},"/teams/{tid}/features/sso":{"get":{"description":" [internal route ID: (\"get\", SSOConfig)]\n\n","operationId":"get_SSOConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SSOConfig_NjcyMjU4MDY2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for sso"}},"/teams/{tid}/features/stealthUsers":{"get":{"description":" [internal route ID: (\"get\", StealthUsersConfig)]\n\n","operationId":"get_StealthUsersConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_StealthUsersConfig_LTE1MTk2NzIz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for stealthUsers"}},"/teams/{tid}/features/validateSAMLemails":{"get":{"description":" [internal route ID: (\"get\", RequireExternalEmailVerificationConfig)]\n\n

Controls whether externally managed email addresses (from SAML or SCIM) must be verified by the user, or are auto-activated.

The external feature name is kept as validateSAMLemails for backward compatibility. That name is misleading because the feature also applies to SCIM-managed users, and it controls email ownership verification rather than generic email validation.

","operationId":"get_RequireExternalEmailVerificationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_RqiExnmVfCg_NjUyMzgzNzY5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for validateSAMLemails"}},"/teams/{tid}/get-members-by-ids-using-post":{"post":{"description":" [internal route ID: \"get-team-members-by-ids\"]\n\nThe `has_more` field in the response body is always `false`.","operationId":"get-team-members-by-ids","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Maximum results to be returned","in":"query","name":"maxResults","required":false,"schema":{"format":"int32","maximum":2000,"minimum":1,"type":"integer"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserIdList_MzA1MTI1Njgx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMemberList_Optional_LTM1ODE2MzM0"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"too-many-uids","message":"Can only process 2000 user ids per request."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["too-many-uids"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `maxResults`\n\nCan only process 2000 user ids per request. (label: `too-many-uids`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"}},"summary":"Get team members by user id list"}},"/teams/{tid}/invitations":{"get":{"description":" [internal route ID: \"get-team-invitations\"]\n\n","operationId":"get-team-invitations","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Invitation id to start from (ascending).","in":"query","name":"start","required":false,"schema":{"format":"uuid","type":"string"}},{"description":"Number of results to return (default 100, max 500).","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":500,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitationList_ODk4NTQxODc3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InvitationList_ODk4NTQxODc3"}}},"description":"List of sent invitations"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}},"summary":"List the sent team invitations"},"post":{"description":" [internal route ID: \"send-team-invitation\"]\n\nInvitations are sent by email. The maximum allowed number of pending team invitations is equal to the team size.","operationId":"send-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InvitationRequest_LTcyMDIzNDc0"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}}},"description":"Invitation was created and sent.","headers":{"Location":{"schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code","invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid invitation code. (label: `invalid-invitation-code`)\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions","too-many-team-invitations","blacklisted-email","no-identity","no-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)\n\nToo many team invitations for this team (label: `too-many-team-invitations`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nThe user has no verified email (label: `no-identity`)\n\nThis operation requires the user to have a verified email address. (label: `no-email`)"}},"summary":"Create and send a new team invitation."}},"/teams/{tid}/invitations/{iid}":{"delete":{"description":" [internal route ID: \"delete-team-invitation\"]\n\n","operationId":"delete-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"iid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"Invitation deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}},"summary":"Delete a pending team invitation by ID."},"get":{"description":" [internal route ID: \"get-team-invitation\"]\n\n","operationId":"get-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"iid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}}},"description":"Invitation"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Notification not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Notification not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `iid` or Notification not found. (label: `not-found`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"duplicate-entry","message":"Entry already exists"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["duplicate-entry"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Entry already exists (label: `duplicate-entry`)"}},"summary":"Get a pending team invitation by ID."}},"/teams/{tid}/legalhold/consent":{"post":{"description":" [internal route ID: \"consent-to-legal-hold\"]\n\n","operationId":"consent-to-legal-hold","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"201":{"description":"Grant consent successful"},"204":{"description":"Consent already granted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam member not found (label: `no-team-member`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}},"summary":"Consent to legal hold"}},"/teams/{tid}/legalhold/settings":{"delete":{"description":" [internal route ID: \"delete-legal-hold-settings\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to members with a legalhold client (via brig)\n- UserLegalHoldDisabled event to contacts of members with a legalhold client (via brig)","operationId":"delete-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RemoveLegalHoldSettingsRequest_NzMzMzMyMDAz"}}},"required":true},"responses":{"204":{"description":"Legal hold service settings deleted"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-disable-unimplemented","message":"legal hold cannot be disabled for whitelisted teams"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-disable-unimplemented","legalhold-not-enabled","invalid-op","action-denied","no-team-member","operation-denied","code-authentication-required","code-authentication-failed","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}},"summary":"Delete legal hold service settings"},"get":{"description":" [internal route ID: \"get-legal-hold-settings\"]\n\n","operationId":"get-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}},"summary":"Get legal hold service settings"},"post":{"description":" [internal route ID: \"create-legal-hold-settings\"]\n\n","operationId":"create-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewLegalHoldService_Mzg0ODQ5NDU1"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}}},"description":"Legal hold service settings created"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-status-bad","message":"legal hold service: invalid response"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-status-bad","legalhold-invalid-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)\n\nlegal hold service pubkey is invalid (label: `legalhold-invalid-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}},"summary":"Create legal hold service settings"}},"/teams/{tid}/legalhold/{uid}":{"delete":{"description":" [internal route ID: \"disable-legal-hold-for-user\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to the user owning the client (via brig)\n- UserLegalHoldDisabled event to contacts of the user owning the client (via brig)","operationId":"disable-legal-hold-for-user","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DisableLegalHoldForUserRequest_LTYyMDYxOTEy"}}},"required":true},"responses":{"200":{"description":"Disable legal hold successful"},"204":{"description":"Legal hold was not enabled"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","action-denied","code-authentication-required","code-authentication-failed","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}},"summary":"Disable legal hold for user"},"get":{"description":" [internal route ID: \"get-legal-hold\"]\n\n","operationId":"get-legal-hold","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserLegalHoldStatusResponse_LTQ1MzUxMTE3"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"}},"summary":"Get legal hold status"},"post":{"description":" [internal route ID: \"request-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- LegalHoldClientRequested event to contacts of the user the device is requested for, if they didn't already have a legalhold client (via brig)","operationId":"request-legal-hold-device","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"201":{"description":"Request device successful"},"204":{"description":"Request device already pending"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered","legalhold-status-bad"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service has not been registered for this team (label: `legalhold-not-registered`)\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","operation-denied","no-team-member","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-legal-hold-not-allowed","message":"A user who is under legal-hold may not participate in MLS conversations"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-legal-hold-not-allowed","legalhold-no-consent","legalhold-already-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"A user who is under legal-hold may not participate in MLS conversations (label: `mls-legal-hold-not-allowed`)\n\nuser has not given consent to using legal hold (label: `legalhold-no-consent`)\n\nlegal hold is already enabled for this user (label: `legalhold-already-enabled`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-illegal-op","message":"internal server error: inconsistent change of user's legalhold state"},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-illegal-op","legalhold-internal"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"internal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)\n\nlegal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)"}},"summary":"Request legal hold device"}},"/teams/{tid}/legalhold/{uid}/approve":{"put":{"description":" [internal route ID: \"approve-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientAdded event to the user owning the client (via brig)\n- UserLegalHoldEnabled event to contacts of the user owning the client (via brig)\n- ClientRemoved event to the user, if removing old client due to max number (via brig)","operationId":"approve-legal-hold-device","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ApproveLegalHoldForUserRequest_NjEyNzYyMTIx"}}},"required":true},"responses":{"200":{"description":"Legal hold approved"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","no-team-member","action-denied","access-denied","code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"legalhold-no-device-allocated","message":"no legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["legalhold-no-device-allocated"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nno legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow. (label: `legalhold-no-device-allocated`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"legalhold-already-enabled","message":"legal hold is already enabled for this user"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["legalhold-already-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is already enabled for this user (label: `legalhold-already-enabled`)"},"412":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":412,"label":"legalhold-not-pending","message":"legal hold cannot be approved without being in a pending state"},"properties":{"code":{"enum":[412],"type":"integer"},"label":{"enum":["legalhold-not-pending"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold cannot be approved without being in a pending state (label: `legalhold-not-pending`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}},"summary":"Approve legal hold device"}},"/teams/{tid}/members":{"get":{"description":" [internal route ID: \"get-team-members\"]\n\n","operationId":"get-team-members","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Maximum results to be returned","in":"query","name":"maxResults","required":false,"schema":{"format":"int32","maximum":2000,"minimum":1,"type":"integer"}},{"description":"Optional, when not specified, the first page will be returned.Every returned page contains a `pagingState`, this should be supplied to retrieve the next page.","in":"query","name":"pagingState","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMembersPage_NzYwNDIxODgx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"}},"summary":"Get team members"},"put":{"description":" [internal route ID: \"update-team-member\"]\n\n","operationId":"update-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewTeamMember_Required_LTg2NjU5OTI2"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","too-many-team-admins","invalid-permissions","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nMaximum number of admins per team reached (label: `too-many-team-admins`)\n\nThe specified permissions are invalid (label: `invalid-permissions`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member","no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam member not found (label: `no-team-member`)\n\nTeam not found (label: `no-team`)"}},"summary":"Update an existing team member"}},"/teams/{tid}/members/csv":{"get":{"description":" [internal route ID: \"get-team-members-csv\"]\n\nThe endpoint returns data in chunked transfer encoding. Internal server errors might result in a failed transfer instead of a 500 response.","operationId":"get-team-members-csv","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/csv":{}},"description":"CSV of team members"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"You do not have permission to access this resource"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"You do not have permission to access this resource (label: `access-denied`)"}},"summary":"Get all members of the team as a CSV file"}},"/teams/{tid}/members/{uid}":{"delete":{"description":" [internal route ID: \"delete-team-member\"]\n\n","operationId":"delete-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMemberDeleteData_LTg2OTEyOTI4"}}},"required":true},"responses":{"200":{"description":""},"202":{"description":"Team member scheduled for deletion"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","access-denied","code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam not found (label: `no-team`)\n\nTeam member not found (label: `no-team-member`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"}},"summary":"Remove an existing team member"},"get":{"description":" [internal route ID: \"get-team-member\"]\n\n","operationId":"get-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMember_Optional_NTU0MDcyNzI1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"}},"summary":"Get single team member"}},"/teams/{tid}/search":{"get":{"description":" [internal route ID: \"browse-team\"]\n\n","operationId":"browse-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Search expression","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"description":"Role filter, eg. `member,partner`. Empty list means do not filter.","in":"query","name":"frole","required":false,"schema":{"items":{"enum":["owner","admin","member","partner"],"type":"string"},"type":"array"}},{"description":"Can be one of name, handle, email, saml_idp, managed_by, role, created_at.","in":"query","name":"sortby","required":false,"schema":{"enum":["name","handle","email","saml_idp","managed_by","role","created_at"],"type":"string"}},{"description":"Can be one of asc, desc.","in":"query","name":"sortorder","required":false,"schema":{"enum":["asc","desc"],"type":"string"}},{"description":"Number of results to return (min: 1, max: 500, default: 15)","in":"query","name":"size","required":false,"schema":{"maximum":500,"minimum":1,"type":"integer"}},{"description":"Optional, when not specified, the first page will be returned. Every returned page contains a `paging_state`, this should be supplied to retrieve the next page.","in":"query","name":"pagingState","required":false,"schema":{"type":"string"}},{"description":"Filter for (un-)verified email","in":"query","name":"email","required":false,"schema":{"enum":["unverified","verified"],"type":"string"}},{"description":"Optional, return only non-searchable members when false.","in":"query","name":"searchable","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResult_TeamContact_LTE0NjQ0NzMw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SearchResult_TeamContact_LTE0NjQ0NzMw"}}},"description":"Search results"}},"summary":"Browse team for members (requires add-user permission)"}},"/teams/{tid}/search-visibility":{"get":{"description":" [internal route ID: \"get-search-visibility\"]\n\n","operationId":"get-search-visibility","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSearchVisibilityView_Mzg3MzMzMTk3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}},"summary":"Shows the value for search visibility"},"put":{"description":" [internal route ID: \"set-search-visibility\"]\n\n","operationId":"set-search-visibility","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSearchVisibilityView_Mzg3MzMzMTk3"}}},"required":true},"responses":{"204":{"description":"Search visibility set"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"team-search-visibility-not-enabled","message":"Custom search is not available for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["team-search-visibility-not-enabled","operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Custom search is not available for this team (label: `team-search-visibility-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Sets the search visibility for the whole team"}},"/teams/{tid}/size":{"get":{"description":" [internal route ID: \"get-team-size\"]\n\nCan be out of sync by roughly the `refresh_interval` of the ES index.","operationId":"get-team-size","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamSize_LTMzMzk2MTk1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSize_LTMzMzk2MTk1"}}},"description":"Number of team members"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid invitation code. (label: `invalid-invitation-code`)"}},"summary":"Get the number of team members as an integer"}},"/time":{"get":{"description":" [internal route ID: \"get-server-time\"]\n\nReturns the current server time in UTC with seconds precision.","operationId":"get-server-time","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServerTime_LTM4NTI3MzIx"}}},"description":""}},"summary":"Get the current server time"}},"/upgrade-personal-to-team":{"post":{"description":" [internal route ID: \"upgrade-personal-to-team\"]\n\n","operationId":"upgrade-personal-to-team","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/BindingNewTeamUser_LTY0MDQxMDEw"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserTeam_MzI4NDQ1Mzkw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateUserTeam_MzI4NDQ1Mzkw"}}},"description":"Team created"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"user-already-in-a-team","message":"Switching teams is not allowed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-already-in-a-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-already-in-a-team","message":"Switching teams is not allowed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-already-in-a-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Switching teams is not allowed (label: `user-already-in-a-team`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"User not found (label: `not-found`)"}},"summary":"Upgrade personal user to team owner"}},"/user-groups":{"get":{"description":" [internal route ID: \"get-user-groups\"]\n\n","operationId":"get-user-groups","parameters":[{"description":"Search string","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"in":"query","name":"sort_by","required":false,"schema":{"enum":["name","created_at"],"type":"string"}},{"in":"query","name":"sort_order","required":false,"schema":{"enum":["asc","desc"],"type":"string"}},{"in":"query","name":"page_size","required":false,"schema":{"description":"integer from [1..500]","type":"number"}},{"description":"`name` of the last seen user group, used to get the next page when sorting by name.","in":"query","name":"last_seen_name","required":false,"schema":{"maxLength":4000,"minLength":1,"type":"string"}},{"description":"`created_at` field of the last seen user group, used to get the next page when sorting by created_at.","in":"query","name":"last_seen_created_at","required":false,"schema":{"format":"yyyy-mm-ddThh:MM:ssZ","type":"string"}},{"description":"`id` of the last seen group, used to get the next page. **Must** be sent to get the next page.","in":"query","name":"last_seen_id","required":false,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"include_channels","schema":{"default":false,"type":"boolean"}},{"allowEmptyValue":true,"in":"query","name":"include_member_count","schema":{"default":false,"type":"boolean"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupPage_UserGroup_Const_LTMxNDg5MDAy"}}},"description":""}},"summary":"Fetch groups accessible to the logged-in user"},"post":{"description":" [internal route ID: \"create-user-group\"]\n\n","operationId":"create-user-group","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewUserGroup_MzYxODU0OTU1"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nOnly team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"}}}},"/user-groups/check-name":{"post":{"description":" [internal route ID: \"check-user-group-name-available\"]\n\n","operationId":"check-user-group-name-available","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CheckUserGroupName_LTg0ODU1OTk1"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserGroupNameAvailability_LTYzMDE1NTk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupNameAvailability_LTYzMDE1NTk4"}}},"description":"OK"}},"summary":"[STUB] Check if a user group name is available"}},"/user-groups/{gid}":{"delete":{"description":" [internal route ID: \"delete-user-group\"]\n\n","operationId":"delete-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User group deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}},"get":{"description":" [internal route ID: \"get-user-group\"]\n\n","operationId":"get-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"include_channels","schema":{"default":false,"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}}},"description":"User Group Found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or User group not found (label: `user-group-not-found`)\n\nUser group not found (label: `user-group-not-found`)"}},"summary":"Fetch a group accessible to the logged-in user"},"put":{"description":" [internal route ID: \"update-user-group\"]\n\n","operationId":"update-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupUpdate_MjUyNTA3Mjgy"}}},"required":true},"responses":{"200":{"description":"User added updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}}},"/user-groups/{gid}/channels":{"put":{"description":" [internal route ID: \"update-user-group-channels\"]\n\n","operationId":"update-user-group-channels","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"append_only","schema":{"default":false,"type":"boolean"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateUserGroupChannels_LTIyMjcwMTMx"}}},"required":true},"responses":{"200":{"description":"User group channels updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}},"summary":"Replaces the channels with the given list."}},"/user-groups/{gid}/users":{"post":{"description":" [internal route ID: \"add-users-to-group-bulk\"]\n\n","operationId":"add-users-to-group-bulk","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupAddUsers_LTgzOTYzNzk0"}}},"required":true},"responses":{"204":{"description":"Users added to group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nOnly team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}},"put":{"description":" [internal route ID: \"update-user-group-members\"]\n\n","operationId":"update-user-group-members","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateUserGroupMembers_LTg1MzQ2NDY3"}}},"required":true},"responses":{"200":{"description":"User group members updated"}},"summary":"[STUB] Update user group members. Replaces the users with the given list."}},"/user-groups/{gid}/users/{uid}":{"delete":{"description":" [internal route ID: \"remove-user-from-group\"]\n\n","operationId":"remove-user-from-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User removed from group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or `uid` not found\n\nUser group not found (label: `user-group-not-found`)"}}},"post":{"description":" [internal route ID: \"add-user-to-group\"]\n\n","operationId":"add-user-to-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User added to group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or `uid` not found\n\nUser group not found (label: `user-group-not-found`)"}}}},"/users/list-clients":{"post":{"description":" [internal route ID: \"list-clients-bulk@v2\"]\n\nIf a backend is unreachable, the clients from that backend will be omitted from the response","operationId":"list-clients-bulk@v2","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LimitedQualifiedUserIdList_500"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"qualified_user_map":{"$ref":"#/components/schemas/QualifiedUserMap_Set_PubClient"}},"type":"object"}}},"description":""}},"summary":"List all clients for a set of user ids"}},"/users/list-prekeys":{"post":{"description":" [internal route ID: \"get-multi-user-prekey-bundle-qualified\"]\n\nYou can't request information for more users than maximum conversation size.","operationId":"get-multi-user-prekey-bundle-qualified","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QualifiedUserClients"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QualifiedUserClientPrekeyMapV4_MzcxOTAxMTYy"}}},"description":""}},"summary":"(deprecated) Given a map of user IDs to client IDs return a prekey for each one."}},"/users/{uid_domain}/{uid}":{"get":{"description":" [internal route ID: \"get-user-qualified\"]\n\n","operationId":"get-user-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}}},"description":"User found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`uid_domain` or `uid` or User not found (label: `not-found`)"}},"summary":"Get a user by Domain and UserId"}},"/users/{uid_domain}/{uid}/clients/{client}":{"get":{"description":" [internal route ID: \"get-user-client-qualified\"]\n\nHint: to list all clients of one or more users, use POST /users/list-clients.","operationId":"get-user-client-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PubClient"}}},"description":""}},"summary":"Get a specific client of a user"}},"/users/{uid_domain}/{uid}/prekeys":{"get":{"description":" [internal route ID: \"get-users-prekey-bundle-qualified\"]\n\n","operationId":"get-users-prekey-bundle-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PrekeyBundle_MzgzOTk4MjYz"}}},"description":""}},"summary":"Get a prekey for each client of a user."}},"/users/{uid_domain}/{uid}/prekeys/{client}":{"get":{"description":" [internal route ID: \"get-users-prekeys-client-qualified\"]\n\n","operationId":"get-users-prekeys-client-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientPrekey_LTcyODUzMTcw"}}},"description":""}},"summary":"Get a prekey for a specific client of a user."}},"/users/{uid_domain}/{uid}/supported-protocols":{"get":{"description":" [internal route ID: \"get-supported-protocols\"]\n\n","operationId":"get-supported-protocols","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array","uniqueItems":true}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array","uniqueItems":true}}},"description":"Protocols supported by the user"}},"summary":"Get a user's supported protocols"}},"/users/{uid}/email":{"put":{"description":" [internal route ID: \"update-user-email\"]\n\nIf the user has a pending email validation, the validation email will be resent.","operationId":"update-user-email","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/EmailUpdate_NjQ5MDg1OTY0"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""}},"summary":"Resend email address validation email."}},"/users/{uid}/rich-info":{"get":{"description":" [internal route ID: \"get-rich-info\"]\n\n","operationId":"get-rich-info","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RichInfoAssocList"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RichInfoAssocList"}}},"description":"Rich info about the user"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}},"summary":"Get a user's rich info"}},"/users/{uid}/searchable":{"post":{"description":" [internal route ID: \"set-user-searchable\"]\n\n","operationId":"set-user-searchable","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SetSearchable_NDAxODAxODI5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""}},"summary":"Set user's visibility in search"}},"/verification-code/send":{"post":{"description":" [internal route ID: \"send-verification-code\"]\n\n","operationId":"send-verification-code","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SendVerificationCode_MjgxNDgxODE2"}}},"required":true},"responses":{"200":{"description":"Verification code sent."}},"summary":"Send a verification code to a given email address."}},"/websocket":{"get":{"description":" [internal route ID: \"websocket\"]\n\nThis is a temporary copy of await, please do not use it","externalDocs":{"description":"RFC 6455","url":"https://datatracker.ietf.org/doc/html/rfc6455"},"operationId":"websocket","parameters":[{"description":"Client ID","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"101":{"description":"Connection upgraded."},"426":{"description":"Upgrade required."}},"summary":"Establish websocket connection"}}},"security":[{"ZAuth":[]}],"servers":[{"url":"/v16"}]} \ No newline at end of file +{"components":{"schemas":{"ASCII":{"example":"aGVsbG8","type":"string"},"AcceptTeamInvitation_Nzg5NzI3MjA2":{"description":"Accept an invitation to join a team on Wire.","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"password":{"description":"The user account password.","maxLength":1024,"minLength":6,"type":"string"}},"required":["code","password"],"type":"object"},"AccessRoleLegacy_LTYwOTAxMDI1":{"deprecated":true,"description":"Deprecated, please use access_role_v2","enum":["private","team","activated","non_activated"],"type":"string"},"AccessRole_Mzk3MDYzMzcw":{"description":"Which users/services can join conversations. This replaces legacy access roles and allows a more fine grained configuration of access roles, and in particular a separation of guest and services access.\n\nThis field is optional. If it is not present, the default will be `[team_member, non_team_member, service]`. Please note that an empty list is not allowed when creating a new conversation.","enum":["team_member","non_team_member","guest","service"],"type":"string"},"AccessTokenType_LTgyOTY0NDE5":{"enum":["DPoP"],"type":"string"},"AccessToken_ODIyMTczMjMw":{"properties":{"access_token":{"description":"The opaque access token string","type":"string"},"expires_in":{"description":"The number of seconds this token is valid","type":"integer"},"token_type":{"$ref":"#/components/schemas/TokenType_NTkyMzk4MjIz"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","access_token","token_type","expires_in"],"type":"object"},"Access_NjkyMzE5ODc0":{"description":"How users can join conversations","enum":["private","invite","link","code"],"type":"string"},"AccountStatus_NzkzNDU1ODU5":{"enum":["active","suspended","deleted","ephemeral","pending-invitation"],"type":"string"},"Action":{"enum":["add_conversation_member","remove_conversation_member","modify_conversation_name","modify_conversation_message_timer","modify_conversation_receipt_mode","modify_conversation_access","modify_other_conversation_member","leave_conversation","delete_conversation","modify_add_permission"],"type":"string"},"Activate_MzUzNzIxODUw":{"description":"Data for an activation request.","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"dryrun":{"description":"At least one of key, email, or phone has to be present while key takes precedence over email, and email takes precedence over phone. Whether to perform a dryrun, i.e. to only check whether activation would succeed. Dry-runs never issue access cookies or tokens on success but failures still count towards the maximum failure count.","type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"key":{"$ref":"#/components/schemas/ASCII"}},"required":["code","dryrun"],"type":"object"},"ActivationResponse_LTIyOTY5NDE3":{"description":"Response body of a successful activation request","properties":{"email":{"$ref":"#/components/schemas/Email"},"first":{"description":"Whether this is the first successful activation (i.e. account activation).","type":"boolean"},"sso_id":{"$ref":"#/components/schemas/UserSSOId"}},"type":"object"},"AddBotResponse_ODA5MzA2NTA1":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"client":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"event":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"}},"required":["id","client","name","accent_id","assets","event"],"type":"object"},"AddBot_NjI0ODkyODk3":{"properties":{"locale":{"$ref":"#/components/schemas/Locale"},"provider":{"$ref":"#/components/schemas/UUID"},"service":{"$ref":"#/components/schemas/UUID"}},"required":["provider","service"],"type":"object"},"AddPermissionUpdate_LTU3MzEwOTY4":{"description":"The action of changing the permission to add members to a channel","properties":{"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"}},"required":["add_permission"],"type":"object"},"AddPermission_LTE1MzgzNzE3":{"enum":["admins","everyone"],"type":"string"},"AllowedGlobalOperationsConfig_MzAwOTU1MDkx":{"properties":{"mlsConversationReset":{"type":"boolean"}},"required":["mlsConversationReset"],"type":"object"},"Alpha_LTE4NDUxNDQ4":{"description":"ISO 4217 alphabetic codes. This is only stored by the backend, not processed. It can be removed once billing supports currency changes after team creation.","enum":["AED","AFN","ALL","AMD","ANG","AOA","ARS","AUD","AWG","AZN","BAM","BBD","BDT","BGN","BHD","BIF","BMD","BND","BOB","BOV","BRL","BSD","BTN","BWP","BYN","BZD","CAD","CDF","CHE","CHF","CHW","CLF","CLP","CNY","COP","COU","CRC","CUC","CUP","CVE","CZK","DJF","DKK","DOP","DZD","EGP","ERN","ETB","EUR","FJD","FKP","GBP","GEL","GHS","GIP","GMD","GNF","GTQ","GYD","HKD","HNL","HRK","HTG","HUF","IDR","ILS","INR","IQD","IRR","ISK","JMD","JOD","JPY","KES","KGS","KHR","KMF","KPW","KRW","KWD","KYD","KZT","LAK","LBP","LKR","LRD","LSL","LYD","MAD","MDL","MGA","MKD","MMK","MNT","MOP","MRO","MUR","MVR","MWK","MXN","MXV","MYR","MZN","NAD","NGN","NIO","NOK","NPR","NZD","OMR","PAB","PEN","PGK","PHP","PKR","PLN","PYG","QAR","RON","RSD","RUB","RWF","SAR","SBD","SCR","SDG","SEK","SGD","SHP","SLL","SOS","SRD","SSP","STD","SVC","SYP","SZL","THB","TJS","TMT","TND","TOP","TRY","TTD","TWD","TZS","UAH","UGX","USD","USN","UYI","UYU","UZS","VEF","VND","VUV","WST","XAF","XAG","XAU","XBA","XBB","XBC","XBD","XCD","XDR","XOF","XPD","XPF","XPT","XSU","XTS","XUA","XXX","YER","ZAR","ZMW","ZWL"],"example":"EUR","type":"string"},"AppInfo_MjgwNTkwOTUz":{"properties":{"category":{"description":"Category name (if uncertain, pick \"other\")","type":"string"},"description":{"maxLength":300,"minLength":0,"type":"string"}},"required":["category","description"],"type":"object"},"AppLockConfigB_Covered_Identity_NDIxOTc2Njkz":{"properties":{"enforceAppLock":{"type":"boolean"},"inactivityTimeoutSecs":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["enforceAppLock","inactivityTimeoutSecs"],"type":"object"},"ApproveLegalHoldForUserRequest_NjEyNzYyMTIx":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"AssetKey":{"description":"S3 asset key for an icon image with retention information.","example":"3-1-47de4580-ae51-4650-acbb-d10c028cb0ac","type":"string"},"AssetSize_OTAwMDA3ODY2":{"enum":["preview","complete"],"type":"string"},"AssetSource":{},"Asset_LTIyMjc1NDEz":{"properties":{"key":{"$ref":"#/components/schemas/AssetKey"},"size":{"$ref":"#/components/schemas/AssetSize_OTAwMDA3ODY2"},"type":{"$ref":"#/components/schemas/MTYxOTI3NjM3"}},"required":["key","type"],"type":"object"},"Asset_Qualified_AssetKey_MzU1MjMxNTA5":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"expires":{"$ref":"#/components/schemas/UTCTimeMillis"},"key":{"$ref":"#/components/schemas/AssetKey"},"token":{"$ref":"#/components/schemas/ASCII"}},"required":["key","domain"],"type":"object"},"AuthSFTServer_LTY5MzcyOTE0":{"description":"Inspired by WebRTC 'RTCIceServer' object, contains details of SFT servers","properties":{"credential":{"$ref":"#/components/schemas/ASCII"},"urls":{"description":"Array containing exactly one SFT server address of the form 'https://:'","items":{"$ref":"#/components/schemas/HttpsUrl"},"type":"array"},"username":{"$ref":"#/components/schemas/SFTUsername"}},"required":["urls"],"type":"object"},"AuthnRequest":{"properties":{"iD":{"$ref":"#/components/schemas/Id_AuthnRequest"},"issueInstant":{"$ref":"#/components/schemas/Time"},"issuer":{"$ref":"#/components/schemas/URI"},"nameIDPolicy":{"$ref":"#/components/schemas/NameIdPolicy"}},"required":["iD","issueInstant","issuer"],"type":"object"},"Base64ByteString":{"example":"ZXhhbXBsZQo=","type":"string"},"Base64URLByteString":{"example":"ZXhhbXBsZQo=","type":"string"},"BaseProtocolTag_LTM0MDE1NTEx":{"enum":["proteus","mls"],"type":"string"},"BindingNewTeamUser_LTY0MDQxMDEw":{"properties":{"currency":{"$ref":"#/components/schemas/Alpha_LTE4NDUxNDQ4"},"icon":{"$ref":"#/components/schemas/Icon"},"icon_key":{"description":"The decryption key for the team icon S3 asset","maxLength":256,"minLength":1,"type":"string"},"name":{"description":"team name","maxLength":256,"minLength":1,"type":"string"}},"required":["name","icon"],"type":"object"},"BotConvView_LTYzMjIzMjQz":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"members":{"items":{"$ref":"#/components/schemas/OtherMember_LTgzNzE2MTk4"},"type":"array"},"name":{"type":"string"}},"required":["id","members"],"type":"object"},"BotUserView_LTE2MTkwMTcw":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"handle":{"$ref":"#/components/schemas/Handle"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"team":{"$ref":"#/components/schemas/UUID"}},"required":["id","name","accent_id"],"type":"object"},"CellsBackend_LTE1Nzg3NzQ2":{"properties":{"url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["url"],"type":"object"},"CellsCollaboraStatus_MTgzNTQyNzUz":{"properties":{"enabled":{"type":"boolean"}},"required":["enabled"],"type":"object"},"CellsCollabora_LTMzNDA5MDIz":{"properties":{"edition":{"$ref":"#/components/schemas/CollaboraEdition_LTg2NDA1NDQ4"}},"required":["edition"],"type":"object"},"CellsConfigB_Covered_Identity_LTE1NzkwOTcz":{"example":{"channels":{"default":"enabled","enabled":true},"collabora":{"enabled":false},"groups":{"default":"enabled","enabled":true},"metadata":{"namespaces":{"usermetaTags":{"allowFreeValues":true,"defaultValues":[]}}},"one2one":{"default":"enabled","enabled":true},"publicLinks":{"enableFiles":true,"enableFolders":true,"enforceExpirationDefault":0,"enforceExpirationMax":0,"enforcePassword":false},"storage":{"perFileQuotaBytes":"100000000","recycle":{"allowSkip":false,"autoPurgeDays":30,"disable":false}},"users":{"externals":true,"guests":false}},"properties":{"channels":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"collabora":{"$ref":"#/components/schemas/CellsCollaboraStatus_MTgzNTQyNzUz"},"groups":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"metadata":{"$ref":"#/components/schemas/CellsMetadata_LTY1OTM5MTM0"},"one2one":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"publicLinks":{"$ref":"#/components/schemas/CellsPublicLinks_MjgxMzQ3Mzk4"},"storage":{"$ref":"#/components/schemas/CellsConfigStorage_LTM0NDMwODM4"},"users":{"$ref":"#/components/schemas/CellsUsers_LTQ4NTEyODA1"}},"required":["channels","groups","one2one","users","collabora","publicLinks","storage","metadata"],"type":"object"},"CellsConfigStorage_LTM0NDMwODM4":{"properties":{"perFileQuotaBytes":{"type":"string"},"recycle":{"$ref":"#/components/schemas/CellsRecycle_LTQxMTg3NTkx"}},"required":["perFileQuotaBytes","recycle"],"type":"object"},"CellsInternalConfigB_Covered_Identity_LTUzMDkwOTAz":{"properties":{"backend":{"$ref":"#/components/schemas/CellsBackend_LTE1Nzg3NzQ2"},"collabora":{"$ref":"#/components/schemas/CellsCollabora_LTMzNDA5MDIz"},"storage":{"$ref":"#/components/schemas/CellsStorage_LTY2Mzc5NzY1"}},"required":["backend","collabora","storage"],"type":"object"},"CellsMetadata_LTY1OTM5MTM0":{"properties":{"namespaces":{"$ref":"#/components/schemas/CellsNamespaces_MzUxMjEzOTQw"}},"required":["namespaces"],"type":"object"},"CellsNamespaces_MzUxMjEzOTQw":{"properties":{"usermetaTags":{"$ref":"#/components/schemas/CellsUserMetaTags_LTc4Njk4NTY0"}},"required":["usermetaTags"],"type":"object"},"CellsPropertyStatus_MTQ5NjE2MzQ4":{"enum":["enabled","disabled","enforced"],"type":"string"},"CellsProperty_NzcxMDIzMzk0":{"properties":{"default":{"$ref":"#/components/schemas/CellsPropertyStatus_MTQ5NjE2MzQ4"},"enabled":{"type":"boolean"}},"required":["enabled","default"],"type":"object"},"CellsPublicLinks_MjgxMzQ3Mzk4":{"properties":{"enableFiles":{"type":"boolean"},"enableFolders":{"type":"boolean"},"enforceExpirationDefault":{"format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"enforceExpirationMax":{"format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"enforcePassword":{"type":"boolean"}},"required":["enableFiles","enableFolders","enforcePassword","enforceExpirationMax","enforceExpirationDefault"],"type":"object"},"CellsRecycle_LTQxMTg3NTkx":{"properties":{"allowSkip":{"type":"boolean"},"autoPurgeDays":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"disable":{"type":"boolean"}},"required":["autoPurgeDays","disable","allowSkip"],"type":"object"},"CellsState_LTg4MDEwNDA5":{"enum":["disabled","pending","ready"],"type":"string"},"CellsStorage_LTY2Mzc5NzY1":{"properties":{"perUserQuotaBytes":{"type":"string"}},"required":["perUserQuotaBytes"],"type":"object"},"CellsUserMetaTags_LTc4Njk4NTY0":{"properties":{"allowFreeValues":{"type":"boolean"},"defaultValues":{"items":{"type":"string"},"type":"array"}},"required":["defaultValues","allowFreeValues"],"type":"object"},"CellsUsers_LTQ4NTEyODA1":{"properties":{"externals":{"type":"boolean"},"guests":{"type":"boolean"}},"required":["externals","guests"],"type":"object"},"ChallengeToken_Mzk3NTcwOTM3":{"properties":{"challenge_token":{"$ref":"#/components/schemas/Token"}},"required":["challenge_token"],"type":"object"},"ChannelPermissions_Mzc1MTM3NTg2":{"enum":["team-members","everyone","admins"],"type":"string"},"ChannelsConfigB_Covered_Identity_ODk2MTk3NDQ4":{"properties":{"allowed_to_create_channels":{"$ref":"#/components/schemas/ChannelPermissions_Mzc1MTM3NTg2"},"allowed_to_open_channels":{"$ref":"#/components/schemas/ChannelPermissions_Mzc1MTM3NTg2"}},"required":["allowed_to_create_channels","allowed_to_open_channels"],"type":"object"},"CheckHandles_LTc0OTkxMzAx":{"properties":{"handles":{"items":{"type":"string"},"maxItems":50,"minItems":1,"type":"array"},"return":{"maximum":10,"minimum":1,"type":"integer"}},"required":["handles","return"],"type":"object"},"CheckUserGroupName_LTg0ODU1OTk1":{"properties":{"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["name"],"type":"object"},"CipherSuiteTag":{"description":"The cipher suite of the corresponding MLS group","maximum":65535,"minimum":0,"type":"integer"},"ClassifiedDomainsConfig_LTg4MDcwMDg2":{"properties":{"domains":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["domains"],"type":"object"},"ClientCapabilityList":{"items":{"$ref":"#/components/schemas/ClientCapability_MTY2NDAzMjM3"},"type":"array"},"ClientCapability_MTY2NDAzMjM3":{"enum":["legalhold-implicit-consent","consumable-notifications"],"type":"string"},"ClientClass_NjE3MDgwNzcx":{"enum":["phone","tablet","desktop","legalhold"],"type":"string"},"ClientIdentity_MjAxMjI3NTUw":{"properties":{"client_id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"domain":{"$ref":"#/components/schemas/Domain"},"user_id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","user_id","client_id"],"type":"object"},"ClientMismatch_ODUyODM0MDQ0":{"properties":{"deleted":{"$ref":"#/components/schemas/UserClients"},"missing":{"$ref":"#/components/schemas/UserClients"},"redundant":{"$ref":"#/components/schemas/UserClients"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["time","missing","redundant","deleted"],"type":"object"},"ClientPrekey_LTcyODUzMTcw":{"properties":{"client":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"prekey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"}},"required":["client","prekey"],"type":"object"},"ClientType_MjQ0OTQwMzcw":{"enum":["temporary","permanent","legalhold"],"type":"string"},"Client_MTM1OTcwOTQ1":{"properties":{"capabilities":{"$ref":"#/components/schemas/ClientCapabilityList"},"class":{"$ref":"#/components/schemas/ClientClass_NjE3MDgwNzcx"},"cookie":{"type":"string"},"id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"label":{"type":"string"},"last_active":{"$ref":"#/components/schemas/UTCTime"},"mls_public_keys":{"$ref":"#/components/schemas/MLSPublicKeys"},"model":{"type":"string"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"},"type":{"$ref":"#/components/schemas/ClientType_MjQ0OTQwMzcw"}},"required":["id","type","time"],"type":"object"},"CodeChallengeMethod_NTIxNzk0NDgw":{"description":"The method used to encode the code challenge. Only `S256` is supported.","enum":["S256"],"type":"string"},"CollaboraEdition_LTg2NDA1NDQ4":{"enum":["NO","CODE","COOL"],"type":"string"},"CollaboratorPermission_NDg5NTg2ODgy":{"description":"

Permission granted to a team collaborator.

  • `create_team_conversation`: equivalent to the `CreateConversation` and `AddRemoveConvMember` permissions for team members (both implied in the `member` role); allows creating team group conversations and adding members to them.
  • \n
  • `implicit_connection`: team members are implicitly connected to each other, allowing conversations (1:1 or group) without an explicit connection request. This permission grants the same to a collaborator.
\n

NB: a member of team A can always open conversations with a collaborator of team A; the permission only controls the collaborator's abilities.

","enum":["create_team_conversation","implicit_connection"],"type":"string"},"CommitBundle":{"description":"This object can only be parsed in TLS format. Please refer to the MLS specification for details."},"CompletePasswordReset_LTYzMDAxNDA1":{"properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"},"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["key","code","password"],"type":"object"},"CompletePasswordReset_NDcyMjY5OTc4":{"description":"Data to complete a password reset","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"email":{"$ref":"#/components/schemas/Email"},"key":{"$ref":"#/components/schemas/ASCII"},"password":{"description":"New password (6 - 1024 characters)","maxLength":1024,"minLength":8,"type":"string"},"phone":{"$ref":"#/components/schemas/PhoneNumber"}},"required":["code","password"],"type":"object"},"ConferenceCallingConfigB_Covered_Identity_NDMwMjcyMzM1":{"properties":{"useSFTForOneToOneCalls":{"type":"boolean"}},"type":"object"},"Connect_ODY3OTE4NTYx":{"properties":{"email":{"type":"string"},"message":{"type":"string"},"name":{"type":"string"},"qualified_recipient":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"recipient":{"$ref":"#/components/schemas/UUID"}},"required":["qualified_recipient"],"type":"object"},"ConnectionUpdate_LTU3MTA1OTA5":{"properties":{"status":{"$ref":"#/components/schemas/Relation_LTE4OTU5MTk4"}},"required":["status"],"type":"object"},"Connections_PagingState":{"type":"string"},"Contact_LTcwODE3Mjc5":{"description":"Contact discovered through search","properties":{"accent_id":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"handle":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"}},"required":["qualified_id","name","type"],"type":"object"},"ConvMembers_LTc2MDg1NDg2":{"description":"Users of a conversation","properties":{"others":{"description":"All other current users of this conversation","items":{"$ref":"#/components/schemas/OtherMember_LTgzNzE2MTk4"},"type":"array"},"self":{"$ref":"#/components/schemas/Member_OTA5OTgyNzcw"}},"required":["others"],"type":"object"},"ConvTeamInfo_Mzc5NjcyNjAz":{"description":"Team information of this conversation","properties":{"managed":{"description":"This field MUST NOT be used by clients. It is here only for backwards compatibility of the interface."},"teamid":{"$ref":"#/components/schemas/UUID"}},"required":["teamid","managed"],"type":"object"},"ConvType_MzM0NTE3ODE5":{"enum":[0,1,2,3],"type":"integer"},"ConversationAccessData_MjMxMTI5ODc3":{"properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"}},"required":["access","access_role"],"type":"object"},"ConversationCodeInfo_LTc5MzgzNjg3":{"description":"Contains conversation properties to update","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"has_password":{"description":"Whether the conversation has a password","type":"boolean"},"key":{"$ref":"#/components/schemas/ASCII"},"uri":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["key","code","uri","has_password"],"type":"object"},"ConversationCode_Mjg3OTI1NTMx":{"description":"Contains conversation properties to update","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"}},"required":["key","code"],"type":"object"},"ConversationCoverView_LTMwNDkxMTA1":{"description":"Limited view of Conversation.","properties":{"has_password":{"type":"boolean"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"}},"required":["id","has_password"],"type":"object"},"ConversationHistoryUpdate_LTg5MDQ5Nzgx":{"properties":{"history":{"$ref":"#/components/schemas/History"}},"required":["history"],"type":"object"},"ConversationIds_PagingState":{"type":"string"},"ConversationMessageTimerUpdate_LTcxMjUwNzQ4":{"description":"Contains conversation properties to update","properties":{"message_timer":{"format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"type":"object"},"ConversationPage_LTIwMDU2NDI3":{"description":"This is the last page if it contains fewer rows than requested. There may be 0 rows on a page.","properties":{"page":{"items":{"$ref":"#/components/schemas/ConversationSearchResult_NDI0MTcyMDU3"},"type":"array"}},"required":["page"],"type":"object"},"ConversationReceiptModeUpdate_NDE4MzUzNTU3":{"description":"Contains conversation receipt mode to update to. Receipt mode tells clients whether certain types of receipts should be sent in the given conversation or not. How this value is interpreted is up to clients.","properties":{"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["receipt_mode"],"type":"object"},"ConversationRename_ODkwODg1MzQ0":{"properties":{"name":{"description":"The new conversation name","type":"string"}},"required":["name"],"type":"object"},"ConversationReset_MzU1Nzc5MjAw":{"properties":{"group_id":{"$ref":"#/components/schemas/GroupId"},"new_group_id":{"$ref":"#/components/schemas/GroupId"}},"required":["group_id"],"type":"object"},"ConversationRole":{"properties":{"actions":{"description":"The set of actions allowed for this role","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"conversation_role":{"$ref":"#/components/schemas/RoleName"}}},"ConversationRolesList":{"properties":{"conversation_roles":{"items":{"$ref":"#/components/schemas/ConversationRole"},"type":"array"}},"required":["conversation_roles"],"type":"object"},"ConversationSearchResult_NDI0MTcyMDU3":{"properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"admin_count":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"id":{"$ref":"#/components/schemas/UUID"},"member_count":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"}},"required":["id","access","member_count","admin_count"],"type":"object"},"Conversation_LTU5NTc0NTI2":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/ConvMembers_LTc2MDg1NDg2"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"},"ConversationsResponse_NzgwNjAxNjQz":{"description":"Response object for getting metadata of a list of conversations","properties":{"failed":{"description":"The server failed to fetch these conversations, most likely due to network issues while contacting a remote server","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"},"found":{"items":{"$ref":"#/components/schemas/OwnConversation_NDU4NDc3MDgz"},"type":"array"},"not_found":{"description":"These conversations either don't exist or are deleted.","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"}},"required":["found","not_found","failed"],"type":"object"},"CookieList_LTM4MzYwNzAz":{"description":"List of cookie information","properties":{"cookies":{"items":{"$ref":"#/components/schemas/Cookie_LTkyMDA3OTI5"},"type":"array"}},"required":["cookies"],"type":"object"},"CookieType_LTE0MjczNzY3":{"enum":["session","persistent"],"type":"string"},"Cookie_LTkyMDA3OTI5":{"properties":{"created":{"$ref":"#/components/schemas/UTCTime"},"expires":{"$ref":"#/components/schemas/UTCTime"},"id":{"format":"int32","maximum":4294967295,"minimum":0,"type":"integer"},"label":{"type":"string"},"successor":{"format":"int32","maximum":4294967295,"minimum":0,"type":"integer"},"type":{"$ref":"#/components/schemas/CookieType_LTE0MjczNzY3"}},"required":["id","type","created","expires"],"type":"object"},"CreateConversationCodeRequest_NTYzMTA1NDYz":{"description":"Request body for creating a conversation code","properties":{"password":{"description":"Password for accessing the conversation via guest link. Set to null or omit for no password.","maxLength":1024,"minLength":8,"type":"string"}},"type":"object"},"CreateGroupConversation_LTE2NzQxMDI0":{"description":"A created group-conversation object extended with a list of failed-to-add users","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"failed_to_add":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/ConvMembers_LTc2MDg1NDg2"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch","failed_to_add"],"type":"object"},"CreateOAuthAuthorizationCodeRequest_LTQ3NzI1Mjkz":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"code_challenge":{"$ref":"#/components/schemas/OAuthCodeChallenge"},"code_challenge_method":{"$ref":"#/components/schemas/CodeChallengeMethod_NTIxNzk0NDgw"},"redirect_uri":{"$ref":"#/components/schemas/RedirectUrl"},"response_type":{"$ref":"#/components/schemas/OAuthResponseType_ODI2Mjg3NzQx"},"scope":{"description":"The scopes which are requested to get authorization for, separated by a space","type":"string"},"state":{"description":"An opaque value used by the client to maintain state between the request and callback. The authorization server includes this value when redirecting the user-agent back to the client. The parameter SHOULD be used for preventing cross-site request forgery","type":"string"}},"required":["client_id","scope","response_type","redirect_uri","state","code_challenge_method","code_challenge"],"type":"object"},"CreateScimTokenResponse_LTIzOTU2NDU4":{"properties":{"info":{"$ref":"#/components/schemas/ScimTokenInfo_LTI5NjgwNzA1"},"token":{"type":"string"}},"required":["token","info"],"type":"object"},"CreateScimToken_OTY0NjYxMDQ2":{"properties":{"description":{"type":"string"},"idp":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"required":["description"],"type":"object"},"CreateUserTeam_MzI4NDQ1Mzkw":{"properties":{"team_id":{"$ref":"#/components/schemas/UUID"},"team_name":{"type":"string"}},"required":["team_id","team_name"],"type":"object"},"CreatedApp_LTM3NjUxOTY1":{"properties":{"cookie":{"$ref":"#/components/schemas/SomeUserToken"},"user":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}},"required":["user","cookie"],"type":"object"},"CustomBackend_LTQxODI0MjQ0":{"description":"Description of a custom backend","properties":{"config_json_url":{"$ref":"#/components/schemas/HttpsUrl"},"webapp_welcome_url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["config_json_url","webapp_welcome_url"],"type":"object"},"DPoPAccessToken":{"type":"string"},"DPoPAccessTokenResponse_LTgyODU5MDE3":{"properties":{"expires_in":{"format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"token":{"$ref":"#/components/schemas/DPoPAccessToken"},"type":{"$ref":"#/components/schemas/AccessTokenType_LTgyOTY0NDE5"}},"required":["token","type","expires_in"],"type":"object"},"DeleteKeyPackages_LTQxNTcxNjY3":{"properties":{"key_packages":{"items":{"$ref":"#/components/schemas/KeyPackageRef"},"maxItems":1000,"minItems":1,"type":"array"}},"required":["key_packages"],"type":"object"},"DeleteProvider_MzYxMzM3Mjg2":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["password"],"type":"object"},"DeleteService_LTY2NzY5NzMz":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["password"],"type":"object"},"DeleteUser_NjE0MjE2Mjkz":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"DeletionCodeTimeout_LTU1MTk0NDI3":{"properties":{"expires_in":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["expires_in"],"type":"object"},"DisableLegalHoldForUserRequest_LTYyMDYxOTEy":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"Domain":{"example":"example.com","type":"string"},"DomainOwnershipToken_NTU0ODc1NDE5":{"properties":{"domain_ownership_token":{"$ref":"#/components/schemas/Token"}},"required":["domain_ownership_token"],"type":"object"},"DomainRedirectConfigTag_MjE2MDI4MDIw":{"enum":["remove","backend","no-registration"],"type":"string"},"DomainRedirectConfig_NTI5NDE5MDQy":{"properties":{"backend":{"$ref":"#/components/schemas/HttpsUrl_HttpsUrl_NjUyMDgzNzk3"},"domain_redirect":{"$ref":"#/components/schemas/DomainRedirectConfigTag_MjE2MDI4MDIw"}},"required":["domain_redirect","backend"],"type":"object"},"DomainRedirectResponse_V10_LTEyMjI4NTM0":{"properties":{"backend":{"$ref":"#/components/schemas/HttpsUrl_Maybe_HttpsUrl_LTQ1MDkyMzY2"},"domain_redirect":{"$ref":"#/components/schemas/DomainRedirectTag_LTY3NjU1MDEy"},"due_to_existing_account":{"type":"boolean"},"sso_code":{"$ref":"#/components/schemas/UUID"}},"required":["domain_redirect","sso_code","backend"],"type":"object"},"DomainRedirectTag_LTY3NjU1MDEy":{"enum":["none","locked","sso","backend","no-registration","pre-authorized"],"type":"string"},"DomainRegistrationResponse_V10_MjE0NDkxODY4":{"properties":{"authorized_team":{"$ref":"#/components/schemas/UUID"},"backend":{"$ref":"#/components/schemas/HttpsUrl_Maybe_HttpsUrl_LTQ1MDkyMzY2"},"dns_verification_token":{"$ref":"#/components/schemas/ASCII"},"domain":{"$ref":"#/components/schemas/Domain"},"domain_redirect":{"$ref":"#/components/schemas/DomainRedirectTag_LTY3NjU1MDEy"},"sso_code":{"$ref":"#/components/schemas/UUID"},"team":{"$ref":"#/components/schemas/UUID"},"team_invite":{"$ref":"#/components/schemas/TeamInviteTag_LTQyNTMyNzA0"}},"required":["domain","domain_redirect","sso_code","backend","team_invite","team"],"type":"object"},"DomainVerificationChallenge_NjIwMzA1MjE5":{"properties":{"dns_verification_token":{"$ref":"#/components/schemas/ASCII"},"id":{"$ref":"#/components/schemas/UUID"},"token":{"$ref":"#/components/schemas/Token"}},"required":["id","token","dns_verification_token"],"type":"object"},"EdMemberLeftReason_OTAyMDA4NzEw":{"enum":["left","user-deleted","removed"],"type":"string"},"EdMemberLeftReason_QualifiedUserIdList_LTYyOTg2OTQ1":{"properties":{"qualified_user_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"reason":{"$ref":"#/components/schemas/EdMemberLeftReason_OTAyMDA4NzEw"},"user_ids":{"deprecated":true,"description":"Deprecated, use qualified_user_ids","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["reason","qualified_user_ids","user_ids"],"type":"object"},"Either_OAuthAccessTokenRequest_OAuthRefreshAccessTokenRequest":{"oneOf":[{"properties":{"Left":{"$ref":"#/components/schemas/OAuthAccessTokenRequest_LTYyNTcyMzI4"}},"required":["Left"],"title":"Left","type":"object"},{"properties":{"Right":{"$ref":"#/components/schemas/OAuthRefreshAccessTokenRequest_LTUwNjA3OTI1"}},"required":["Right"],"title":"Right","type":"object"}]},"Email":{"type":"string"},"EmailUpdate_LTYwODE0ODQ5":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"EmailUpdate_NjQ5MDg1OTY0":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"EnforceFilDwadLtCgB_vIy_LTQ2NjU1Nzcx":{"properties":{"enforcedDownloadLocation":{"type":"string"}},"type":"object"},"EpochTimestamp":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"},"EventType_LTQ3NTQyNDYz":{"enum":["conversation.member-join","conversation.member-leave","conversation.member-update","conversation.rename","conversation.access-update","conversation.receipt-mode-update","conversation.message-timer-update","conversation.code-update","conversation.code-delete","conversation.create","conversation.delete","conversation.mls-reset","conversation.connect-request","conversation.typing","conversation.otr-message-add","conversation.mls-message-add","conversation.mls-welcome","conversation.protocol-update","conversation.add-permission-update","conversation.history-update"],"type":"string"},"EventVia_Mjc4MzcyNzE0":{"enum":["scim","user"],"type":"string"},"Event_LTMwMTMyODM5":{"properties":{"conversation":{"$ref":"#/components/schemas/UUID"},"data":{"description":"The action of changing the permission to add members to a channel","example":"ZXhhbXBsZQo=","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"$ref":"#/components/schemas/AccessRoleLegacy_LTYwOTAxMDI1"},"access_role_v2":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"add_type":{"$ref":"#/components/schemas/JoinType_LTY4MDg2MzA5"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"code":{"$ref":"#/components/schemas/ASCII"},"conversation_role":{"$ref":"#/components/schemas/RoleName"},"creator":{"$ref":"#/components/schemas/UUID"},"data":{"description":"Extra (symmetric) data (i.e. ciphertext, Base64 in JSON) that is common with all other recipients.","type":"string"},"depth":{"$ref":"#/components/schemas/HistoryDuration"},"email":{"type":"string"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/EpochTimestamp"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"has_password":{"description":"Whether the conversation has a password","type":"boolean"},"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"key":{"$ref":"#/components/schemas/ASCII"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message":{"type":"string"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"new_group_id":{"$ref":"#/components/schemas/GroupId"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_recipient":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"qualified_target":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"qualified_user_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"reason":{"$ref":"#/components/schemas/EdMemberLeftReason_OTAyMDA4NzEw"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"recipient":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"sender":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"status":{"$ref":"#/components/schemas/TypingStatus_LTg5MzcyNDMy"},"target":{"$ref":"#/components/schemas/UUID"},"team":{"$ref":"#/components/schemas/UUID"},"text":{"description":"The ciphertext for the recipient (Base64 in JSON)","type":"string"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"},"uri":{"$ref":"#/components/schemas/HttpsUrl"},"user_ids":{"deprecated":true,"description":"Deprecated, use qualified_user_ids","items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"users":{"items":{"$ref":"#/components/schemas/SimpleMember_NTY5MTcxMzcx"},"type":"array"}},"required":["users","add_type","reason","qualified_user_ids","user_ids","qualified_target","name","access","key","code","uri","has_password","qualified_id","type","members","group_id","epoch","epoch_timestamp","cipher_suite","qualified_recipient","receipt_mode","sender","recipient","text","status","add_permission","depth"],"type":"object"},"from":{"$ref":"#/components/schemas/UUID"},"qualified_conversation":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_from":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"subconv":{"type":"string"},"team":{"$ref":"#/components/schemas/UUID"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"},"type":{"$ref":"#/components/schemas/EventType_LTQ3NTQyNDYz"},"via":{"$ref":"#/components/schemas/EventVia_Mjc4MzcyNzE0"}},"required":["type","data","qualified_conversation","qualified_from","via","time"],"type":"object"},"Featur_EnfocilDwdLCgBIy_MjcxMzc1NDA5":{"properties":{"config":{"$ref":"#/components/schemas/EnforceFilDwadLtCgB_vIy_LTQ2NjU1Nzcx"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Featur_PvnAdmilsGopCfgBIy_LTE2NzM3ODkx":{"properties":{"config":{"$ref":"#/components/schemas/PreventAdminlessGroupsConfigB_Covered_Identity_MjQ2NDYwMTk2"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Featur_SlfDingMsCoBIdy_LTg2MzYzNjc2":{"properties":{"config":{"$ref":"#/components/schemas/SelfDeletingMessagesConfigB_Covered_Identity_LTY0NzQ4MzU1"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"FeatureStatus_LTMzMTUwODEw":{"enum":["enabled","disabled"],"type":"string"},"Feature_AppLockConfigB_Bare_Identity_MTAzMjI5NDYy":{"properties":{"config":{"$ref":"#/components/schemas/AppLockConfigB_Covered_Identity_NDIxOTc2Njkz"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_BackgroundEffectsConfig_MjQyOTkxMDc4":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_CellsConfigB_Bare_Identity_NzU2NjgxNzEw":{"properties":{"config":{"$ref":"#/components/schemas/CellsConfigB_Covered_Identity_LTE1NzkwOTcz"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_ChannelsConfigB_Bare_Identity_LTU4NTE4MTgx":{"properties":{"config":{"$ref":"#/components/schemas/ChannelsConfigB_Covered_Identity_ODk2MTk3NDQ4"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_ConferenceCallingConfigB_Bare_Identity_Njc2NTcxNTI3":{"properties":{"config":{"$ref":"#/components/schemas/ConferenceCallingConfigB_Covered_Identity_NDMwMjcyMzM1"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_ExposeInvitationURLsToTeamAdminConfig_LTY5NzY2Mzg5":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_FileSharingConfig_LTUyNjkxMzM4":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_GuestLinksConfig_NjQyMDMxNjg3":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_LegalholdConfig_NjM3MTkxNjYw":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_MLSConfigB_Bare_Identity_LTI5MjA3MDYy":{"properties":{"config":{"$ref":"#/components/schemas/MLSConfigB_Covered_Identity_LTEzNTk3MzM5"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_MeetingsConfig_NDc2MzM0MDE1":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_MeetingsPremiumConfig_NzE4NjUzMDE0":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_MlsE2EIdConfigB_Bare_Identity_LTUxODYzODEx":{"properties":{"config":{"$ref":"#/components/schemas/MlsE2EIdConfigB_Covered_Identity_NDQ0NjEwNzA3"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_MlsMigrationConfigB_Bare_Identity_LTQyMDAxMTkz":{"properties":{"config":{"$ref":"#/components/schemas/MlsMigrationConfigB_Covered_Identity_LTM3NDQ4Mzg4"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_OutlookCalIntegrationConfig_LTg0MjIxNTMx":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_SearchVisibilityAvailableConfig_LTMzNTkxODI1":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_SearchVisibilityInboundConfig_MTI1NzQxODY2":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"Feature_SndFactorPasswordChallengeConfig_NDc0MzUyMzQz":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"FederatedUserSearchPolicy_MzkwODA4MTM3":{"description":"Search policy that was applied when searching for users","enum":["no_search","exact_handle_search","full_search"],"type":"string"},"Fingerprint":{"example":"ioy3GeIjgQRsobf2EKGO3O8mq/FofFxHRqy0T4ERIZ8=","type":"string"},"FormRedirect":{"properties":{"uri":{"type":"string"},"xml":{"$ref":"#/components/schemas/AuthnRequest"}},"type":"object"},"Frequency_Mzk0ODQwOTM3":{"enum":["daily","weekly","monthly","yearly"],"type":"string"},"GetByEmailReq_LTY4MzE3Njgy":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"GetByEmailResp_LTMxNTY3MjA0":{"properties":{"sso_code":{"$ref":"#/components/schemas/UUID"}},"type":"object"},"GetDomainRegistrationRequest_LTg4NTM1MzM2":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"GetMuliTabPgRqs_ConcLOrm501_LTIwMjA4NzYw":{"description":"A request to list some or all of a user's Connections, including remote ones","properties":{"paging_state":{"$ref":"#/components/schemas/Connections_PagingState"},"size":{"description":"optional, must be <= 500, defaults to 100.","format":"int32","maximum":500,"minimum":1,"type":"integer"}},"type":"object"},"GetMuliTabPgRqs_ConvrIdLcOm10_ODU0NjQ1NDMz":{"description":"A request to list some or all of a user's ConversationIds, including remote ones","properties":{"paging_state":{"$ref":"#/components/schemas/ConversationIds_PagingState"},"size":{"description":"optional, must be <= 1000, defaults to 1000.","format":"int32","maximum":1000,"minimum":1,"type":"integer"}},"type":"object"},"GroupConvType_LTU4NjU0MTY5":{"enum":["group_conversation","channel","meeting"],"type":"string"},"GroupId":{"example":"ZXhhbXBsZQo=","type":"string"},"GroupInfoData":{"description":"This object can only be parsed in TLS format. Please refer to the MLS specification for details."},"Handle":{"type":"string"},"HandleUpdate_NTI4NDk1OTAx":{"properties":{"handle":{"type":"string"}},"required":["handle"],"type":"object"},"History":{"properties":{"depth":{"$ref":"#/components/schemas/HistoryDuration"}},"required":["depth"],"type":"object"},"HistoryDuration":{"type":"string"},"HistorySharingConfig_Mjc4MzA1Nzgw":{"properties":{"depth":{"$ref":"#/components/schemas/HistoryDuration"}},"required":["depth"],"type":"object"},"HttpsUrl":{"example":"https://example.com","type":"string"},"HttpsUrl_HttpsUrl_NjUyMDgzNzk3":{"properties":{"config_url":{"$ref":"#/components/schemas/HttpsUrl"},"webapp_url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["config_url","webapp_url"],"type":"object"},"HttpsUrl_Maybe_HttpsUrl_LTQ1MDkyMzY2":{"properties":{"config_url":{"$ref":"#/components/schemas/HttpsUrl"},"webapp_url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["config_url"],"type":"object"},"Icon":{"description":"S3 asset key for an icon image with retention information. Allows special value 'default'.","example":"3-1-47de4580-ae51-4650-acbb-d10c028cb0ac","type":"string"},"IdObject_ClientId_LTM3NjQyODM5":{"properties":{"id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"}},"required":["id"],"type":"object"},"IdPConfig_WireIdP_NDA5MTE4Mjk0":{"properties":{"extraInfo":{"$ref":"#/components/schemas/WireIdP_ODMzOTExMzYw"},"id":{"$ref":"#/components/schemas/URI"},"metadata":{"$ref":"#/components/schemas/IdPMetadata_MTI3NzE4MTA0"}},"required":["id","metadata","extraInfo"],"type":"object"},"IdPList":{"properties":{"providers":{"items":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"},"type":"array"}},"required":["providers"],"type":"object"},"IdPMetadataInfo":{"maxProperties":1,"minProperties":1,"properties":{"value":{"type":"string"}},"type":"object"},"IdPMetadata_MTI3NzE4MTA0":{"properties":{"certAuthnResponse":{"items":{"$ref":"#/components/schemas/SignedCertificate"},"minItems":1,"type":"array"},"issuer":{"$ref":"#/components/schemas/URI"},"requestURI":{"type":"string"}},"required":["issuer","requestURI","certAuthnResponse"],"type":"object"},"Id_AuthnRequest":{"properties":{"iD":{"type":"string"}},"required":["iD"],"type":"object"},"InvitationList_ODk4NTQxODc3":{"description":"A list of sent team invitations.","properties":{"has_more":{"description":"Indicator that the server has more invitations than returned.","type":"boolean"},"invitations":{"items":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"},"type":"array"}},"required":["invitations","has_more"],"type":"object"},"InvitationRequest_LTcyMDIzNDc0":{"description":"A request to join a team on Wire.","properties":{"allow_existing":{"description":"Whether invitations to existing users are allowed.","type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"locale":{"$ref":"#/components/schemas/Locale"},"name":{"description":"Name of the invitee (1 - 128 characters).","maxLength":128,"minLength":1,"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"}},"required":["email"],"type":"object"},"InvitationUserView_LTUyMTE3Nzkz":{"properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"created_by_email":{"$ref":"#/components/schemas/Email"},"email":{"$ref":"#/components/schemas/Email"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"description":"Name of the invitee (1 - 128 characters)","maxLength":128,"minLength":1,"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"},"team":{"$ref":"#/components/schemas/UUID"},"url":{"$ref":"#/components/schemas/URIRef_Absolute"}},"required":["team","id","created_at","email"],"type":"object"},"Invitation_NTkzMDYwODc1":{"description":"An invitation to join a team on Wire. If invitee is invited from an existing personal account, inviter email is included.","properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"email":{"$ref":"#/components/schemas/Email"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"description":"Name of the invitee (1 - 128 characters)","maxLength":128,"minLength":1,"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"},"team":{"$ref":"#/components/schemas/UUID"},"url":{"$ref":"#/components/schemas/URIRef_Absolute"}},"required":["team","id","created_at","email"],"type":"object"},"InviteQualified_ODYyODIyNjYz":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"qualified_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"minItems":1,"type":"array"}},"required":["qualified_users"],"type":"object"},"JoinConversationByCode_NjgzMzM4Mjg5":{"description":"Request body for joining a conversation by code","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"},"password":{"maxLength":1024,"minLength":8,"type":"string"}},"required":["key","code"],"type":"object"},"JoinType_LTY4MDg2MzA5":{"enum":["external_add","internal_add"],"type":"string"},"KeyMap_Value_MzAxODEwOTgx":{"type":"object"},"KeyPackage":{"example":"a2V5IHBhY2thZ2UgZGF0YQo=","type":"string"},"KeyPackageBundleEntry_NDQ2MzQ2MzMz":{"properties":{"client":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"domain":{"$ref":"#/components/schemas/Domain"},"key_package":{"$ref":"#/components/schemas/KeyPackage"},"key_package_ref":{"$ref":"#/components/schemas/KeyPackageRef"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["domain","user","client","key_package_ref","key_package"],"type":"object"},"KeyPackageBundle_MjU2MjY0MDU2":{"properties":{"key_packages":{"items":{"$ref":"#/components/schemas/KeyPackageBundleEntry_NDQ2MzQ2MzMz"},"type":"array"}},"required":["key_packages"],"type":"object"},"KeyPackageCount_LTYwNDg5MDcz":{"properties":{"count":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["count"],"type":"object"},"KeyPackageRef":{"example":"ZXhhbXBsZQo=","type":"string"},"KeyPackageUpload_NTQ2Mjk2NzEx":{"properties":{"key_packages":{"items":{"$ref":"#/components/schemas/KeyPackage"},"type":"array"}},"required":["key_packages"],"type":"object"},"LHServiceStatus_ODc3NzE0Mjg3":{"enum":["configured","not_configured","disabled"],"type":"string"},"LimitedQualifiedUserIdList_500":{"properties":{"qualified_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["qualified_users"],"type":"object"},"ListConversations_MjkxMTIwODMz":{"description":"A request to list some of a user's conversations, including remote ones. Maximum 1000 qualified conversation IDs","properties":{"qualified_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"maxItems":1000,"minItems":1,"type":"array"}},"required":["qualified_ids"],"type":"object"},"ListType_LTkyMDM4MzA1":{"description":"true if 'members' doesn't contain all team members","enum":[true,false],"type":"boolean"},"ListUsersById_LTQ5MTE3NDc0":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"minItems":1,"type":"array"},"found":{"items":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"},"type":"array"}},"required":["found"],"type":"object"},"ListUsersQuery":{"description":"exactly one of qualified_ids or qualified_handles must be provided.","example":{"qualified_ids":[{"domain":"example.com","id":"00000000-0000-0000-0000-000000000000"}]},"properties":{"qualified_handles":{"items":{"$ref":"#/components/schemas/Qualified_Handle_Nzg0MDE3Nzk4"},"type":"array"},"qualified_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"type":"object"},"Locale":{"type":"string"},"LocaleUpdate_LTgzNjgyOTEw":{"properties":{"locale":{"$ref":"#/components/schemas/Locale"}},"required":["locale"],"type":"object"},"LockStatus_LTIyMTU5OTkw":{"enum":["locked","unlocked"],"type":"string"},"LockableFeature_AllowedGlobalOperationsConfig_NjQ1MjA5MDYw":{"properties":{"config":{"$ref":"#/components/schemas/AllowedGlobalOperationsConfig_MzAwOTU1MDkx"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5":{"properties":{"config":{"$ref":"#/components/schemas/AppLockConfigB_Covered_Identity_NDIxOTc2Njkz"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_AppsConfig_MzQyNTMxNTk5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_AssetAuditLogConfig_NDQ3MzcyMzk2":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_BackgroundEffectsConfig_MTg1MTk3NTM5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3":{"properties":{"config":{"$ref":"#/components/schemas/CellsConfigB_Covered_Identity_LTE1NzkwOTcz"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw":{"properties":{"config":{"$ref":"#/components/schemas/ChannelsConfigB_Covered_Identity_ODk2MTk3NDQ4"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_ChatBubblesConfig_NDgwMTQzNjI2":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_ClassifiedDomainsConfig_LTY1ODQwODg1":{"properties":{"config":{"$ref":"#/components/schemas/ClassifiedDomainsConfig_LTg4MDcwMDg2"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_ConsumableNotificationsConfig_MjUxMjczMjM0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_DigitalSignaturesConfig_ODM2MDA2NDU4":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_DomainRegistrationConfig_NzU0NjczNTE0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_FileSharingConfig_MjgwNjIzODEz":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_GuestLinksConfig_LTcwNjU0NDMw":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_LegalholdConfig_LTc5MTk5OTIw":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_LimitedEventFanoutConfig_MTg3ODM0NzU0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw":{"properties":{"config":{"$ref":"#/components/schemas/MLSConfigB_Covered_Identity_LTEzNTk3MzM5"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_MeetingsPremiumConfig_NDg1ODEyNTA1":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4":{"properties":{"config":{"$ref":"#/components/schemas/MlsE2EIdConfigB_Covered_Identity_NDQ0NjEwNzA3"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_SSOConfig_NjcyMjU4MDY2":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_StealthUsersConfig_LTE1MTk2NzIz":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFtur_CnfigBIdy_NzY1NDU5MDAy":{"properties":{"config":{"$ref":"#/components/schemas/ConferenceCallingConfigB_Covered_Identity_NDMwMjcyMzM1"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFtur_CsInfigBdy_LTQ3MTU3Mjg0":{"properties":{"config":{"$ref":"#/components/schemas/CellsInternalConfigB_Covered_Identity_LTUzMDkwOTAz"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4":{"properties":{"config":{"$ref":"#/components/schemas/EnforceFilDwadLtCgB_vIy_LTQ2NjU1Nzcx"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFtur_MsignCfBIdy_LTE1NjAxNjU2":{"properties":{"config":{"$ref":"#/components/schemas/MlsMigrationConfigB_Covered_Identity_LTM3NDQ4Mzg4"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw":{"properties":{"config":{"$ref":"#/components/schemas/PreventAdminlessGroupsConfigB_Covered_Identity_MjQ2NDYwMTk2"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFtur_RqiExnmVfCg_NjUyMzgzNzY5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2":{"properties":{"config":{"$ref":"#/components/schemas/SelfDeletingMessagesConfigB_Covered_Identity_LTY0NzQ4MzU1"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFtur_SimpfdUsCnRqQg_Njk5NjU4OTgy":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Login_LTgyNTIzMTM1":{"properties":{"email":{"$ref":"#/components/schemas/Email"},"handle":{"$ref":"#/components/schemas/Handle"},"label":{"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"required":["password"],"type":"object"},"MLSConfigB_Covered_Identity_LTEzNTk3MzM5":{"description":"allowlist of users that may change protocols","properties":{"allowedCipherSuites":{"items":{"$ref":"#/components/schemas/CipherSuiteTag"},"type":"array"},"defaultCipherSuite":{"$ref":"#/components/schemas/CipherSuiteTag"},"defaultProtocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"groupInfoDiagnostics":{"type":"boolean"},"protocolToggleUsers":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"supportedProtocols":{"items":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"type":"array"}},"required":["protocolToggleUsers","defaultProtocol","allowedCipherSuites","defaultCipherSuite","supportedProtocols"],"type":"object"},"MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx":{"properties":{"removal":{"$ref":"#/components/schemas/MLSKeys_SomeKey_LTUzNDA5MzA3"}},"required":["removal"],"type":"object"},"MLSKeys_SomeKey_LTUzNDA5MzA3":{"properties":{"ecdsa_secp256r1_sha256":{"$ref":"#/components/schemas/SomeKey"},"ecdsa_secp384r1_sha384":{"$ref":"#/components/schemas/SomeKey"},"ecdsa_secp521r1_sha512":{"$ref":"#/components/schemas/SomeKey"},"ed25519":{"$ref":"#/components/schemas/SomeKey"}},"required":["ed25519","ecdsa_secp256r1_sha256","ecdsa_secp384r1_sha384","ecdsa_secp521r1_sha512"],"type":"object"},"MLSMessage":{"description":"This object can only be parsed in TLS format. Please refer to the MLS specification for details."},"MLSMessageSendingStatus_NjA1NDA0MTE4":{"properties":{"events":{"description":"A list of events caused by sending the message.","items":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"},"type":"array"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["events","time"],"type":"object"},"MLSOne2OneConversation_SomeKey_LTMxNjE2NjUy":{"properties":{"conversation":{"$ref":"#/components/schemas/v9_OwnConversation_NDU4NDc3MDgz"},"public_keys":{"$ref":"#/components/schemas/MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx"}},"required":["conversation","public_keys"],"type":"object"},"MLSPublicKeys":{"additionalProperties":{"example":"ZXhhbXBsZQo=","type":"string"},"description":"Mapping from signature scheme (tags) to public key data","example":{"ecdsa_secp256r1_sha256":"ZXhhbXBsZQo=","ecdsa_secp384r1_sha384":"ZXhhbXBsZQo=","ecdsa_secp521r1_sha512":"ZXhhbXBsZQo=","ed25519":"ZXhhbXBsZQo="},"type":"object"},"MLSReset_NzgwODA3ODc4":{"properties":{"epoch":{"format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"group_id":{"$ref":"#/components/schemas/GroupId"}},"required":["group_id","epoch"],"type":"object"},"MTYxOTI3NjM3":{"enum":["image"],"type":"string"},"ManagedBy_NTI0ODc0NTQx":{"enum":["wire","scim"],"type":"string"},"MeetingEmailsInvitation_NzgyNzUzMzcz":{"description":"Emails invitation","properties":{"emails":{"items":{"$ref":"#/components/schemas/Email"},"type":"array"}},"required":["emails"],"type":"object"},"Meeting_ODU0OTMzMTgw":{"description":"A scheduled meeting","properties":{"created_at":{"$ref":"#/components/schemas/UTCTime"},"end_time":{"$ref":"#/components/schemas/UTCTime"},"invited_emails":{"items":{"$ref":"#/components/schemas/Email"},"type":"array"},"qualified_conversation":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_creator":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Meeting_MjgxMDUyNjc3"},"recurrence":{"$ref":"#/components/schemas/Recurrence_LTQ0OTc0ODE2"},"start_time":{"$ref":"#/components/schemas/UTCTime"},"title":{"maxLength":256,"minLength":1,"type":"string"},"trial":{"type":"boolean"},"updated_at":{"$ref":"#/components/schemas/UTCTime"}},"required":["qualified_id","title","qualified_creator","start_time","end_time","qualified_conversation","invited_emails","trial","created_at","updated_at"],"type":"object"},"MemberUpdateData_LTc3Nzc3NTEy":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"qualified_target":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"target":{"$ref":"#/components/schemas/UUID"}},"required":["qualified_target"],"type":"object"},"MemberUpdate_LTg4NTQ0OTYz":{"properties":{"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"type":"object"},"Member_OTA5OTgyNzcw":{"description":"The user ID of the requestor","properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"status":{},"status_ref":{},"status_time":{}},"required":["qualified_id"],"type":"object"},"MembersJoin_LTg0MDc1NjQ3":{"properties":{"add_type":{"$ref":"#/components/schemas/JoinType_LTY4MDg2MzA5"},"user_ids":{"deprecated":true,"description":"deprecated","items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"users":{"items":{"$ref":"#/components/schemas/SimpleMember_NTY5MTcxMzcx"},"type":"array"}},"required":["users","add_type"],"type":"object"},"MessageSendingStatus_ODg0NDgyNDk4":{"description":"The Proteus message sending status. It has these fields:\n- `time`: Time of sending message.\n- `missing`: Clients that the message /should/ have been encrypted for, but wasn't.\n- `redundant`: Clients that the message /should not/ have been encrypted for, but was.\n- `deleted`: Clients that were deleted.\n- `failed_to_send`: When message sending fails for some clients but succeeds for others, e.g., because a remote backend is unreachable, this field will contain the list of clients for which the message sending failed. This list should be empty when message sending is not even tried, like when some clients are missing.","properties":{"deleted":{"$ref":"#/components/schemas/QualifiedUserClients"},"failed_to_confirm_clients":{"$ref":"#/components/schemas/QualifiedUserClients"},"failed_to_send":{"$ref":"#/components/schemas/QualifiedUserClients"},"missing":{"$ref":"#/components/schemas/QualifiedUserClients"},"redundant":{"$ref":"#/components/schemas/QualifiedUserClients"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["time","missing","redundant","deleted","failed_to_send","failed_to_confirm_clients"],"type":"object"},"MlsE2EIdConfigB_Covered_Identity_NDQ0NjEwNzA3":{"description":"When a client first tries to fetch or renew a certificate, they may need to login to an identity provider (IdP) depending on their IdP domain authentication policy. The user may have a grace period during which they can \"snooze\" this login. The duration of this grace period (in seconds) is set in the `verificationDuration` parameter, which is enforced separately by each client. After the grace period has expired, the client will not allow the user to use the application until they have logged to refresh the certificate. The default value is 1 day (86400s). The client enrolls using the Automatic Certificate Management Environment (ACME) protocol. The `acmeDiscoveryUrl` parameter must be set to the HTTPS URL of the ACME server discovery endpoint for this team. It is of the form \"https://acme.{backendDomain}/acme/{provisionerName}/discovery\". For example: `https://acme.example.com/acme/provisioner1/discovery`.","properties":{"acmeDiscoveryUrl":{"$ref":"#/components/schemas/HttpsUrl"},"crlProxy":{"$ref":"#/components/schemas/HttpsUrl"},"useProxyOnMobile":{"type":"boolean"},"verificationExpiration":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["verificationExpiration"],"type":"object"},"MlsMigrationConfigB_Covered_Identity_LTM3NDQ4Mzg4":{"properties":{"finaliseRegardlessAfter":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"},"startTime":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"}},"type":"object"},"MultiTabePg_ConcsLOrRmU_MTgwMzU4OTU5":{"properties":{"connections":{"items":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"},"type":"array"},"has_more":{"type":"boolean"},"paging_state":{"$ref":"#/components/schemas/Connections_PagingState"}},"required":["connections","has_more","paging_state"],"type":"object"},"MultiTabePg_ConvrsIdqf_cLORm_Q_Mzg0MjYzNzM0":{"properties":{"has_more":{"type":"boolean"},"paging_state":{"$ref":"#/components/schemas/ConversationIds_PagingState"},"qualified_conversations":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"}},"required":["qualified_conversations","has_more","paging_state"],"type":"object"},"NP_LockableFtur_ghdCnfiSOVsyAvIRqExmDpB_MGwUT2Q_LTM1Mzc0ODgy":{"properties":{"allowedGlobalOperations":{"$ref":"#/components/schemas/LockableFeature_AllowedGlobalOperationsConfig_NjQ1MjA5MDYw"},"appLock":{"$ref":"#/components/schemas/LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5"},"apps":{"$ref":"#/components/schemas/LockableFeature_AppsConfig_MzQyNTMxNTk5"},"assetAuditLog":{"$ref":"#/components/schemas/LockableFeature_AssetAuditLogConfig_NDQ3MzcyMzk2"},"backgroundEffects":{"$ref":"#/components/schemas/LockableFeature_BackgroundEffectsConfig_MTg1MTk3NTM5"},"cells":{"$ref":"#/components/schemas/LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3"},"cellsInternal":{"$ref":"#/components/schemas/LockableFtur_CsInfigBdy_LTQ3MTU3Mjg0"},"channels":{"$ref":"#/components/schemas/LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw"},"chatBubbles":{"$ref":"#/components/schemas/LockableFeature_ChatBubblesConfig_NDgwMTQzNjI2"},"classifiedDomains":{"$ref":"#/components/schemas/LockableFeature_ClassifiedDomainsConfig_LTY1ODQwODg1"},"conferenceCalling":{"$ref":"#/components/schemas/LockableFtur_CnfigBIdy_NzY1NDU5MDAy"},"consumableNotifications":{"$ref":"#/components/schemas/LockableFeature_ConsumableNotificationsConfig_MjUxMjczMjM0"},"conversationGuestLinks":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"},"digitalSignatures":{"$ref":"#/components/schemas/LockableFeature_DigitalSignaturesConfig_ODM2MDA2NDU4"},"domainRegistration":{"$ref":"#/components/schemas/LockableFeature_DomainRegistrationConfig_NzU0NjczNTE0"},"enforceFileDownloadLocation":{"$ref":"#/components/schemas/LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4"},"exposeInvitationURLsToTeamAdmin":{"$ref":"#/components/schemas/LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1"},"fileSharing":{"$ref":"#/components/schemas/LockableFeature_FileSharingConfig_MjgwNjIzODEz"},"legalhold":{"$ref":"#/components/schemas/LockableFeature_LegalholdConfig_LTc5MTk5OTIw"},"limitedEventFanout":{"$ref":"#/components/schemas/LockableFeature_LimitedEventFanoutConfig_MTg3ODM0NzU0"},"meetings":{"$ref":"#/components/schemas/LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw"},"meetingsPremium":{"$ref":"#/components/schemas/LockableFeature_MeetingsPremiumConfig_NDg1ODEyNTA1"},"mls":{"$ref":"#/components/schemas/LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw"},"mlsE2EId":{"$ref":"#/components/schemas/LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4"},"mlsMigration":{"$ref":"#/components/schemas/LockableFtur_MsignCfBIdy_LTE1NjAxNjU2"},"outlookCalIntegration":{"$ref":"#/components/schemas/LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0"},"preventAdminlessGroups":{"$ref":"#/components/schemas/LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw"},"searchVisibility":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5"},"searchVisibilityInbound":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy"},"selfDeletingMessages":{"$ref":"#/components/schemas/LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2"},"simplifiedUserConnectionRequestQRCode":{"$ref":"#/components/schemas/LockableFtur_SimpfdUsCnRqQg_Njk5NjU4OTgy"},"sndFactorPasswordChallenge":{"$ref":"#/components/schemas/LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx"},"sso":{"$ref":"#/components/schemas/LockableFeature_SSOConfig_NjcyMjU4MDY2"},"stealthUsers":{"$ref":"#/components/schemas/LockableFeature_StealthUsersConfig_LTE1MTk2NzIz"},"validateSAMLemails":{"$ref":"#/components/schemas/LockableFtur_RqiExnmVfCg_NjUyMzgzNzY5"}},"required":["legalhold","sso","searchVisibility","searchVisibilityInbound","validateSAMLemails","digitalSignatures","appLock","fileSharing","classifiedDomains","conferenceCalling","selfDeletingMessages","conversationGuestLinks","sndFactorPasswordChallenge","mls","exposeInvitationURLsToTeamAdmin","outlookCalIntegration","mlsE2EId","mlsMigration","enforceFileDownloadLocation","limitedEventFanout","domainRegistration","channels","preventAdminlessGroups","cells","allowedGlobalOperations","consumableNotifications","chatBubbles","apps","simplifiedUserConnectionRequestQRCode","assetAuditLog","stealthUsers","cellsInternal","meetings","meetingsPremium","backgroundEffects"],"type":"object"},"NameIDFormat":{"enum":["NameIDFUnspecified","NameIDFEmail","NameIDFX509","NameIDFWindows","NameIDFKerberos","NameIDFEntity","NameIDFPersistent","NameIDFTransient"],"type":"string"},"NameIdPolicy":{"properties":{"allowCreate":{"type":"boolean"},"format":{"$ref":"#/components/schemas/NameIDFormat"},"spNameQualifier":{"type":"string"}},"required":["format","allowCreate"],"type":"object"},"NewApp_LTQwODMwMzQ4":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"category":{"description":"Category name (if uncertain, pick \"other\")","type":"string"},"description":{"maxLength":300,"minLength":0,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["name","category","description","password"],"type":"object"},"NewAssetToken_NTAwMDQwODYy":{"properties":{"token":{"$ref":"#/components/schemas/ASCII"}},"required":["token"],"type":"object"},"NewClient_ODg1NjY4Njgy":{"properties":{"capabilities":{"$ref":"#/components/schemas/ClientCapabilityList"},"class":{"$ref":"#/components/schemas/ClientClass_NjE3MDgwNzcx"},"cookie":{"description":"The cookie label, i.e. the label used when logging in.","type":"string"},"label":{"type":"string"},"lastkey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"mls_public_keys":{"$ref":"#/components/schemas/MLSPublicKeys"},"model":{"type":"string"},"password":{"description":"The password of the authenticated user for verification. Note: Required for registration of the 2nd, 3rd, ... client.","maxLength":1024,"minLength":6,"type":"string"},"prekeys":{"description":"Prekeys for other clients to establish OTR sessions.","items":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"type":"array"},"type":{"$ref":"#/components/schemas/ClientType_MjQ0OTQwMzcw"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"required":["prekeys","lastkey","type"],"type":"object"},"NewConv_LTgzNTk1NDQx":{"description":"JSON object to create a new conversation. When using 'qualified_users' (preferred), you can omit 'users'","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells":{"type":"boolean"},"conversation_role":{"$ref":"#/components/schemas/RoleName"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"history":{"$ref":"#/components/schemas/History"},"message_timer":{"description":"Per-conversation message timer","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"maxLength":256,"minLength":1,"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"qualified_users":{"description":"List of qualified user IDs (excluding the requestor) to be part of this conversation","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"skip_creator":{"description":"Don't add creator to the conversation, only works for team admins not wanting to be part of the channels they create.","type":"boolean"},"team":{"$ref":"#/components/schemas/ConvTeamInfo_Mzc5NjcyNjAz"},"users":{"deprecated":true,"description":"List of user IDs (excluding the requestor) to be part of this conversation (deprecated)","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"type":"object"},"NewLegalHoldService_Mzg0ODQ5NDU1":{"properties":{"auth_token":{"$ref":"#/components/schemas/ASCII"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"public_key":{"$ref":"#/components/schemas/ServiceKeyPEM"}},"required":["base_url","public_key","auth_token"],"type":"object"},"NewMeeting_LTI1NTMzOTU5":{"description":"Request to create a new meeting","properties":{"end_time":{"$ref":"#/components/schemas/UTCTime"},"invited_emails":{"items":{"$ref":"#/components/schemas/Email"},"type":"array"},"recurrence":{"$ref":"#/components/schemas/Recurrence_LTQ0OTc0ODE2"},"start_time":{"$ref":"#/components/schemas/UTCTime"},"title":{"maxLength":256,"minLength":1,"type":"string"}},"required":["start_time","end_time","title"],"type":"object"},"NewOne2OneConv_LTI3OTc4NDAz":{"description":"JSON object to create a new 1:1 conversation. When using 'qualified_users' (preferred), you can omit 'users'","properties":{"name":{"maxLength":256,"minLength":1,"type":"string"},"qualified_users":{"description":"List of qualified user IDs (excluding the requestor) to be part of this conversation","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"team":{"$ref":"#/components/schemas/ConvTeamInfo_Mzc5NjcyNjAz"},"users":{"deprecated":true,"description":"List of user IDs (excluding the requestor) to be part of this conversation (deprecated)","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"type":"object"},"NewOtrMessage_LTUyMTE5MTMw":{"properties":{"data":{"type":"string"},"native_priority":{"$ref":"#/components/schemas/Priority_ODA3NDM3MDYy"},"native_push":{"type":"boolean"},"recipients":{"$ref":"#/components/schemas/UserClientMap"},"report_missing":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"sender":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"transient":{"type":"boolean"}},"required":["sender","recipients"],"type":"object"},"NewPasswordReset_LTEyNzAxMTcy":{"description":"Data to initiate a password reset","properties":{"email":{"$ref":"#/components/schemas/Email"},"phone":{"description":"Email","type":"string"}},"type":"object"},"NewProviderResponse_OTE0ODI2NjU0":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"password":{"maxLength":1024,"minLength":8,"type":"string"}},"required":["id"],"type":"object"},"NewProvider_LTEyMTY5MjYy":{"properties":{"description":{"maxLength":1024,"minLength":1,"type":"string"},"email":{"$ref":"#/components/schemas/Email"},"name":{"maxLength":128,"minLength":1,"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["name","email","url","description"],"type":"object"},"NewServiceResponse_LTExMzcwMjg5":{"properties":{"auth_token":{"$ref":"#/components/schemas/ASCII"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["id"],"type":"object"},"NewService_LTYwOTU1MDQ3":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"auth_token":{"$ref":"#/components/schemas/ASCII"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"description":{"maxLength":1024,"minLength":1,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"public_key":{"$ref":"#/components/schemas/ServiceKeyPEM"},"summary":{"maxLength":128,"minLength":1,"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"maxItems":3,"minItems":1,"type":"array"}},"required":["name","summary","description","base_url","public_key","assets","tags"],"type":"object"},"NewTeamCollaborator_LTIxNjEzMTYw":{"properties":{"permissions":{"items":{"$ref":"#/components/schemas/CollaboratorPermission_NDg5NTg2ODgy"},"type":"array"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","permissions"],"type":"object"},"NewTeamMember_Required_LTg2NjU5OTI2":{"description":"Required data when creating new team members","properties":{"member":{"description":"the team member to add (the legalhold_status field must be null or missing!)","properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"permissions":{"$ref":"#/components/schemas/Permissions_NDE0ODM5NDUx"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","permissions"],"type":"object"}},"required":["member"],"type":"object"},"NewUserGroup_MzYxODU0OTU1":{"properties":{"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["name","members"],"type":"object"},"NewUser_PlainTextPassword_8_LTI4MzI5NzQx":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"email":{"$ref":"#/components/schemas/Email"},"email_code":{"$ref":"#/components/schemas/ASCII"},"expires_in":{"maximum":604800,"minimum":1,"type":"integer"},"invitation_code":{"$ref":"#/components/schemas/ASCII"},"label":{"type":"string"},"locale":{"$ref":"#/components/schemas/Locale"},"managed_by":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"name":{"maxLength":128,"minLength":1,"type":"string"},"password":{"maxLength":1024,"minLength":8,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"sso_id":{"$ref":"#/components/schemas/UserSSOId"},"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"},"team":{"$ref":"#/components/schemas/BindingNewTeamUser_LTY0MDQxMDEw"},"team_code":{"$ref":"#/components/schemas/ASCII"},"team_id":{"$ref":"#/components/schemas/UUID"},"uuid":{"$ref":"#/components/schemas/UUID"}},"required":["name"],"type":"object"},"OAuthAccessTokenRequest_LTYyNTcyMzI4":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"code":{"$ref":"#/components/schemas/OAuthAuthorizationCode"},"code_verifier":{"description":"The code verifier to complete the code challenge","maxLength":128,"minLength":43,"type":"string"},"grant_type":{"$ref":"#/components/schemas/OAuthGrantType_LTIxODA5NDIw"},"redirect_uri":{"$ref":"#/components/schemas/RedirectUrl"}},"required":["grant_type","client_id","code_verifier","code","redirect_uri"],"type":"object"},"OAuthAccessTokenResponse_NzEwOTI4NjQ0":{"properties":{"access_token":{"description":"The access token, which has a relatively short lifetime","type":"string"},"expires_in":{"description":"The lifetime of the access token in seconds","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"refresh_token":{"description":"The refresh token, which has a relatively long lifetime, and can be used to obtain a new access token","type":"string"},"token_type":{"$ref":"#/components/schemas/OAuthAccessTokenType_MjU3ODI0NDIw"}},"required":["access_token","token_type","expires_in","refresh_token"],"type":"object"},"OAuthAccessTokenType_MjU3ODI0NDIw":{"description":"The type of the access token. Currently only `Bearer` is supported.","enum":["Bearer"],"type":"string"},"OAuthApplication_Mjk5NTUxNjA1":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"name":{"description":"The OAuth client's name","maxLength":256,"minLength":6,"type":"string"},"sessions":{"description":"The OAuth client's sessions","items":{"$ref":"#/components/schemas/OAuthSession_LTQxOTIxNTMy"},"type":"array"}},"required":["id","name","sessions"],"type":"object"},"OAuthAuthorizationCode":{"description":"The authorization code","type":"string"},"OAuthClient_NzExMTI5NTIy":{"properties":{"application_name":{"maxLength":256,"minLength":6,"type":"string"},"client_id":{"$ref":"#/components/schemas/UUID"},"redirect_url":{"$ref":"#/components/schemas/RedirectUrl"}},"required":["client_id","application_name","redirect_url"],"type":"object"},"OAuthCodeChallenge":{"description":"Generated by the client from the code verifier (unpadded base64url-encoded SHA256 hash of the code verifier)","type":"string"},"OAuthGrantType_LTIxODA5NDIw":{"description":"Indicates which authorization flow to use. Use `authorization_code` for authorization code flow.","enum":["authorization_code","refresh_token"],"type":"string"},"OAuthRefreshAccessTokenRequest_LTUwNjA3OTI1":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"grant_type":{"$ref":"#/components/schemas/OAuthGrantType_LTIxODA5NDIw"},"refresh_token":{"description":"The refresh token","type":"string"}},"required":["grant_type","client_id","refresh_token"],"type":"object"},"OAuthResponseType_ODI2Mjg3NzQx":{"description":"Indicates which authorization flow to use. Use `code` for authorization code flow.","enum":["code"],"type":"string"},"OAuthRevokeRefreshTokenRequest_MjA1MDg4MzQ4":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"refresh_token":{"description":"The refresh token","type":"string"}},"required":["client_id","refresh_token"],"type":"object"},"OAuthSession_LTQxOTIxNTMy":{"properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"refresh_token_id":{"$ref":"#/components/schemas/UUID"}},"required":["refresh_token_id","created_at"],"type":"object"},"Object":{"additionalProperties":true,"description":"A single notification event","properties":{"type":{"description":"Event type","type":"string"}},"title":"Event","type":"object"},"OtherMemberUpdate_LTM1MjYzOTU0":{"description":"Update user properties of other members relative to a conversation","properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"}},"type":"object"},"OtherMember_LTgzNzE2MTk4":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"id":{"$ref":"#/components/schemas/UUID"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"status":{"deprecated":true,"description":"deprecated","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["qualified_id"],"type":"object"},"OtrMessage_LTY4MTYzNzg3":{"description":"Encrypted message of a conversation","properties":{"data":{"description":"Extra (symmetric) data (i.e. ciphertext, Base64 in JSON) that is common with all other recipients.","type":"string"},"recipient":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"sender":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"text":{"description":"The ciphertext for the recipient (Base64 in JSON)","type":"string"}},"required":["sender","recipient","text"],"type":"object"},"OwnConvMembers_LTEwMzUzODMy":{"description":"Users of a conversation","properties":{"others":{"description":"All other current users of this conversation","items":{"$ref":"#/components/schemas/OtherMember_LTgzNzE2MTk4"},"type":"array"},"self":{"$ref":"#/components/schemas/Member_OTA5OTgyNzcw"}},"required":["self","others"],"type":"object"},"OwnConversation_NDU4NDc3MDgz":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"},"PagingState":{"description":"Paging state that should be supplied to retrieve the next page of results","type":"string"},"PasswordChange_MTgzMDM2NTY2":{"description":"Data to change a password. The old password is required if a password already exists.","properties":{"new_password":{"maxLength":1024,"minLength":8,"type":"string"},"old_password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["new_password"],"type":"object"},"PasswordChange_NDI0ODgwNDU0":{"properties":{"new_password":{"maxLength":1024,"minLength":6,"type":"string"},"old_password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["old_password","new_password"],"type":"object"},"PasswordReqBody_LTcxMzE3ODE3":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"PasswordReset_LTYzNDYxNTQ3":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"Permissions_NDE0ODM5NDUx":{"description":"This is just a complicated way of representing a team role. self and copy always have to contain the same integer, and only the following integers are allowed: 1025 (partner), 1587 (member), 5951 (admin), 8191 (owner). Unit tests of the galley-types package in wire-server contain an authoritative list.","properties":{"copy":{"description":"Permissions that this user is able to grant others","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"self":{"description":"Permissions that the user has","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["self","copy"],"type":"object"},"PhoneNumber":{"description":"A known phone number with a pending password reset.","type":"string"},"Pict_DEPRECATED_USE_ASSETS_INSTEAD":{"items":{"type":"object"},"maxItems":10,"minItems":0,"type":"array"},"PrekeyBundle_MzgzOTk4MjYz":{"properties":{"clients":{"items":{"$ref":"#/components/schemas/ClientPrekey_LTcyODUzMTcw"},"type":"array"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","clients"],"type":"object"},"PreventAdminlessGroupsConfigB_Covered_Identity_MjQ2NDYwMTk2":{"properties":{"deletionTimeout":{"maximum":18446744073709551615,"minimum":0,"type":"integer"},"promotionStrategy":{"$ref":"#/components/schemas/PreventAdminlessGroupsPromotionStrategy_Mjc1ODA2MDU1"},"reminderTimeouts":{"items":{"maximum":18446744073709551615,"minimum":0,"type":"integer"},"type":"array"}},"required":["promotionStrategy","deletionTimeout","reminderTimeouts"],"type":"object"},"PreventAdminlessGroupsPromotionStrategy_Mjc1ODA2MDU1":{"enum":["alphabetical","random","all"],"type":"string"},"Priority_ODA3NDM3MDYy":{"enum":["low","high"],"type":"string"},"PropertyKeysAndValues":{"type":"object"},"PropertyValue":{"description":"An arbitrary JSON value for a property"},"ProtocolTag_ODg1MTE5NjEw":{"enum":["proteus","mls","mixed"],"type":"string"},"ProtocolUpdate_NzY1ODgxNDQy":{"properties":{"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"}},"type":"object"},"ProviderActivationResponse_LTgzNTU3MzA5":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"ProviderLogin_LTE2MTk2NTM5":{"properties":{"email":{"$ref":"#/components/schemas/Email"},"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["email","password"],"type":"object"},"Provider_NDIyMzQ3ODIy":{"properties":{"description":{"type":"string"},"email":{"$ref":"#/components/schemas/Email"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["id","name","email","url","description"],"type":"object"},"PubClient":{"properties":{"class":{"$ref":"#/components/schemas/ClientClass_NjE3MDgwNzcx"},"id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"}},"required":["id"],"type":"object"},"PublicSubConversation_MjI2NTIxMzU4":{"description":"An MLS subconversation","properties":{"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_id":{"$ref":"#/components/schemas/GroupId"},"members":{"items":{"$ref":"#/components/schemas/ClientIdentity_MjAxMjI3NTUw"},"type":"array"},"parent_qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"subconv_id":{"type":"string"}},"required":["parent_qualified_id","subconv_id","group_id","epoch","members"],"type":"object"},"PushTokenList_NDI0Mjc3MzY3":{"description":"List of Native Push Tokens","properties":{"tokens":{"description":"Push tokens","items":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"},"type":"array"}},"required":["tokens"],"type":"object"},"PushToken_ODYzMDYzOTA4":{"description":"Native Push Token","properties":{"app":{"description":"Application","type":"string"},"client":{"description":"Client ID","type":"string"},"token":{"description":"Access Token","type":"string"},"transport":{"$ref":"#/components/schemas/Transport_NDk2NzU5NDIy"}},"required":["transport","app","token","client"],"type":"object"},"PutApp_LTE4MDc1OTM4":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"category":{"description":"Category name (if uncertain, pick \"other\")","type":"string"},"description":{"maxLength":300,"minLength":0,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"}},"type":"object"},"QualifiedNewOtrMessage":{"description":"This object can only be parsed from Protobuf.\nThe specification for the protobuf types is here: \nhttps://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto."},"QualifiedUserClientPrekeyMapV4_MzcxOTAxMTYy":{"properties":{"failed_to_list":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"qualified_user_client_prekeys":{"additionalProperties":{"$ref":"#/components/schemas/UserClientPrekeyMap"},"type":"object"}},"required":["qualified_user_client_prekeys"],"type":"object"},"QualifiedUserClients":{"additionalProperties":{"additionalProperties":{"items":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"type":"array"},"type":"object"},"description":"Map of Domain to UserClients","example":{"domain1.example.com":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":["60f85e4b15ad3786","6e323ab31554353b"]}},"type":"object"},"QualifiedUserMap_Set_PubClient":{"additionalProperties":{"$ref":"#/components/schemas/UserMap_Set_PubClient"},"description":"Map of Domain to (UserMap (Set_PubClient)).","example":{"domain1.example.com":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":[{"class":"legalhold","id":"d0"}]}},"type":"object"},"Qualified_Handle_Nzg0MDE3Nzk4":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"handle":{"$ref":"#/components/schemas/Handle"}},"required":["domain","handle"],"type":"object"},"Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","id"],"type":"object"},"Qualified_Id_IdTag_Meeting_MjgxMDUyNjc3":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","id"],"type":"object"},"Qualified_Id_IdTag_User_LTQ1NTIwNDM1":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","id"],"type":"object"},"QueuedNotificationList_MTU0ODEyNTQ2":{"description":"Zero or more notifications","properties":{"has_more":{"description":"Whether there are still more notifications.","type":"boolean"},"notifications":{"description":"Notifications","items":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"},"type":"array"},"time":{"$ref":"#/components/schemas/UTCTime"}},"required":["notifications"],"type":"object"},"QueuedNotification_NTY2NzY2MTU2":{"description":"A single notification","properties":{"id":{"$ref":"#/components/schemas/UUID"},"payload":{"description":"List of events","items":{"$ref":"#/components/schemas/Object"},"minItems":1,"type":"array"}},"required":["id","payload"],"type":"object"},"RTCConfiguration_LTIwOTc4OTk0":{"description":"A subset of the WebRTC 'RTCConfiguration' dictionary","properties":{"ice_servers":{"description":"Array of 'RTCIceServer' objects","items":{"$ref":"#/components/schemas/RTCIceServer_LTY1NzExODA0"},"minItems":1,"type":"array"},"is_federating":{"description":"True if the client should connect to an SFT in the sft_servers_all and request it to federate","type":"boolean"},"sft_servers":{"description":"Array of 'SFTServer' objects (optional)","items":{"$ref":"#/components/schemas/SFTServer_NDQ0NDkwNDE2"},"minItems":1,"type":"array"},"sft_servers_all":{"description":"Array of all SFT servers","items":{"$ref":"#/components/schemas/AuthSFTServer_LTY5MzcyOTE0"},"type":"array"},"ttl":{"description":"Number of seconds after which the configuration should be refreshed (advisory)","format":"int32","maximum":4294967295,"minimum":0,"type":"integer"}},"required":["ice_servers","ttl"],"type":"object"},"RTCIceServer_LTY1NzExODA0":{"description":"A subset of the WebRTC 'RTCIceServer' object","properties":{"credential":{"$ref":"#/components/schemas/ASCII"},"urls":{"description":"Array of TURN server addresses of the form 'turn::'","items":{"$ref":"#/components/schemas/TurnURI"},"minItems":1,"type":"array"},"username":{"$ref":"#/components/schemas/TurnUsername"}},"required":["urls","username","credential"],"type":"object"},"Recurrence_LTQ0OTc0ODE2":{"description":"Recurrence pattern for meetings","properties":{"frequency":{"$ref":"#/components/schemas/Frequency_Mzk0ODQwOTM3"},"interval":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"until":{"$ref":"#/components/schemas/UTCTime"}},"required":["frequency"],"type":"object"},"RedirectUrl":{"description":"The URL must match the URL that was used to generate the authorization code.","type":"string"},"RefreshAppCookieRequest_MjEyMDMyMTk5":{"properties":{"password":{"description":"The password of the authenticated admin for verification. or if the user has only SAML credentials.","maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"RefreshAppCookieResponse_LTQ0MjU1NTIw":{"properties":{"cookie":{"$ref":"#/components/schemas/SomeUserToken"}},"required":["cookie"],"type":"object"},"RegisteredDomains_V10_NDYwNzYyMTMy":{"properties":{"registered_domains":{"items":{"$ref":"#/components/schemas/DomainRegistrationResponse_V10_MjE0NDkxODY4"},"type":"array"}},"required":["registered_domains"],"type":"object"},"Relation_LTE4OTU5MTk4":{"enum":["accepted","blocked","pending","ignored","sent","cancelled","missing-legalhold-consent"],"type":"string"},"RemoveBotResponse_LTUxNTQ4MDEy":{"properties":{"event":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"required":["event"],"type":"object"},"RemoveCookies_OTYwMTI0NDMy":{"description":"Data required to remove cookies","properties":{"ids":{"description":"A list of cookie IDs to revoke","items":{"format":"int32","maximum":4294967295,"minimum":0,"type":"integer"},"type":"array"},"labels":{"description":"A list of cookie labels for which to revoke the cookies","items":{"type":"string"},"type":"array"},"password":{"description":"The user's password","maxLength":1024,"minLength":6,"type":"string"}},"required":["password"],"type":"object"},"RemoveLegalHoldSettingsRequest_NzMzMzMyMDAz":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"RichField_LTgwMzc0MTg2":{"properties":{"type":{"type":"string"},"value":{"type":"string"}},"required":["type","value"],"type":"object"},"RichInfoAssocList":{"description":"json object with case-insensitive fields.","properties":{"fields":{"items":{"$ref":"#/components/schemas/RichField_LTgwMzc0MTg2"},"type":"array"},"version":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["version","fields"],"type":"object"},"RmClient_MTQ5OTI2MDY3":{"properties":{"password":{"description":"The password of the authenticated user for verification. The password is not required for deleting temporary clients. or if the user has only SAML credentials.","maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"RoleName":{"description":"Role name, between 2 and 128 chars, 'wire_' prefix is reserved for roles designed by Wire (i.e., no custom roles can have the same prefix)","type":"string"},"Role_LTIzMjAzMjky":{"description":"Role of the invited user","enum":["owner","admin","member","partner"],"type":"string"},"SFTServer_NDQ0NDkwNDE2":{"description":"Inspired by WebRTC 'RTCIceServer' object, contains details of SFT servers","properties":{"urls":{"description":"Array containing exactly one SFT server address of the form 'https://:'","items":{"$ref":"#/components/schemas/HttpsUrl"},"type":"array"}},"required":["urls"],"type":"object"},"SFTUsername":{"description":"String containing the SFT username","type":"string"},"ScimTokenInfo_LTI5NjgwNzA1":{"properties":{"created_at":{"$ref":"#/components/schemas/UTCTime"},"description":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"idp":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"team":{"$ref":"#/components/schemas/UUID"}},"required":["team","id","created_at","description","name"],"type":"object"},"ScimTokenList_NjQwNTYxOTAw":{"properties":{"tokens":{"items":{"$ref":"#/components/schemas/ScimTokenInfo_LTI5NjgwNzA1"},"type":"array"}},"required":["tokens"],"type":"object"},"ScimTokenName_LTgzOTM2OTI4":{"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"},"SearchResult_Contact_OTExNzg4MTE0":{"properties":{"documents":{"description":"List of contacts found","items":{"$ref":"#/components/schemas/Contact_LTcwODE3Mjc5"},"type":"array"},"found":{"description":"Total number of hits","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"has_more":{"description":"Indicates whether there are more results to be fetched","type":"boolean"},"paging_state":{"$ref":"#/components/schemas/PagingState"},"returned":{"description":"Total number of hits returned","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"search_policy":{"$ref":"#/components/schemas/FederatedUserSearchPolicy_MzkwODA4MTM3"},"took":{"description":"Search time in ms","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["found","returned","took","documents","search_policy"],"type":"object"},"SearchResult_TeamContact_LTE0NjQ0NzMw":{"properties":{"documents":{"description":"List of contacts found","items":{"$ref":"#/components/schemas/TeamContact_LTI5MTIxODc0"},"type":"array"},"found":{"description":"Total number of hits","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"has_more":{"description":"Indicates whether there are more results to be fetched","type":"boolean"},"paging_state":{"$ref":"#/components/schemas/PagingState"},"returned":{"description":"Total number of hits returned","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"search_policy":{"$ref":"#/components/schemas/FederatedUserSearchPolicy_MzkwODA4MTM3"},"took":{"description":"Search time in ms","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["found","returned","took","documents","search_policy"],"type":"object"},"SelfDeletingMessagesConfigB_Covered_Identity_LTY0NzQ4MzU1":{"properties":{"enforcedTimeoutSeconds":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["enforcedTimeoutSeconds"],"type":"object"},"SendActivationCode_LTgyNDAxNzEy":{"description":"Data for requesting an email code to be sent. 'email' must be present.","properties":{"email":{"$ref":"#/components/schemas/Email"},"locale":{"$ref":"#/components/schemas/Locale"}},"required":["email"],"type":"object"},"SendVerificationCode_MjgxNDgxODE2":{"properties":{"action":{"$ref":"#/components/schemas/VerificationAction_LTU0MzYxNzUz"},"email":{"$ref":"#/components/schemas/Email"}},"required":["action","email"],"type":"object"},"ServerTime_LTM4NTI3MzIx":{"description":"The current server time","properties":{"time":{"$ref":"#/components/schemas/UTCTime"}},"required":["time"],"type":"object"},"ServiceKeyPEM":{"example":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu+Kg/PHHU3atXrUbKnw0\nG06FliXcNt3lMwl2os5twEDcPPFw/feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPH\nWvUBdiLfGrZqJO223DB6D8K2Su/odmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKV\nVPOaOzgtAB21XKRiQ4ermqgi3/njr03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiS\nbUKr/BeArYRcjzr/h5m1In6fG/if9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg8\n7X883H+LA/d6X5CTiPv1VMxXdBUiGPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7la\nnQIDAQAB\n-----END PUBLIC KEY-----\n","type":"string"},"ServiceKeyType_NTEzNzI4NTA2":{"enum":["rsa"],"type":"string"},"ServiceKey_NzY5NTY5NzYy":{"properties":{"pem":{"$ref":"#/components/schemas/ServiceKeyPEM"},"size":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"type":{"$ref":"#/components/schemas/ServiceKeyType_NTEzNzI4NTA2"}},"required":["type","size","pem"],"type":"object"},"ServiceProfilePage_Njg1NDQ5Njc4":{"properties":{"has_more":{"type":"boolean"},"services":{"items":{"$ref":"#/components/schemas/ServiceProfile_LTc2MDQzNTk3"},"type":"array"}},"required":["has_more","services"],"type":"object"},"ServiceProfile_LTc2MDQzNTk3":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"description":{"type":"string"},"enabled":{"type":"boolean"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"provider":{"$ref":"#/components/schemas/UUID"},"summary":{"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"type":"array"}},"required":["id","provider","name","summary","description","assets","tags","enabled"],"type":"object"},"ServiceRef_LTgxMjY3NzAz":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"provider":{"$ref":"#/components/schemas/UUID"}},"required":["id","provider"],"type":"object"},"ServiceTagList":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"type":"array"},"ServiceTag_LTMyNTEzNjYy":{"enum":["audio","books","business","design","education","entertainment","finance","fitness","food-drink","games","graphics","health","integration","lifestyle","media","medical","movies","music","news","photography","poll","productivity","quiz","rating","shopping","social","sports","travel","tutorial","video","weather"],"type":"string"},"Service_MjcyOTA5NjQx":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"auth_tokens":{"items":{"$ref":"#/components/schemas/ASCII"},"minItems":1,"type":"array"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"description":{"type":"string"},"enabled":{"type":"boolean"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"public_keys":{"items":{"$ref":"#/components/schemas/ServiceKey_NzY5NTY5NzYy"},"minItems":1,"type":"array"},"summary":{"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"type":"array"}},"required":["id","name","summary","description","base_url","auth_tokens","public_keys","assets","tags","enabled"],"type":"object"},"SetSearchable_NDAxODAxODI5":{"properties":{"set_searchable":{"type":"boolean"}},"required":["set_searchable"],"type":"object"},"SignedCertificate":{"type":"string"},"SimpleMember_NTY5MTcxMzcx":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"id":{"$ref":"#/components/schemas/UUID"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"}},"required":["qualified_id"],"type":"object"},"SomeKey":{},"SomeUserToken":{"type":"string"},"SsoSettings":{"properties":{"default_sso_code":{"$ref":"#/components/schemas/URI"}},"type":"object"},"Sso_LTg1MDM5ODQ3":{"properties":{"issuer":{"type":"string"},"nameid":{"type":"string"}},"required":["issuer","nameid"],"type":"object"},"SupportedProtocolUpdate_LTE3Njk3MDM4":{"properties":{"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"}},"required":["supported_protocols"],"type":"object"},"SystemSettingsPublic_LTgwNTMxNjU2":{"properties":{"nomadProfiles":{"description":"Whether Nomad client profiles are enabled; null or absence means not enabled.","type":"boolean"},"setRestrictUserCreation":{"description":"Do not allow certain user creation flows","type":"boolean"}},"required":["setRestrictUserCreation"],"type":"object"},"SystemSettings_ODU3MDk5MTA3":{"properties":{"nomadProfiles":{"description":"Whether Nomad client profiles are enabled; null or absence means not enabled.","type":"boolean"},"setEnableMls":{"description":"Whether MLS is enabled or not","type":"boolean"},"setRestrictUserCreation":{"description":"Do not allow certain user creation flows","type":"boolean"}},"required":["setRestrictUserCreation","setEnableMls"],"type":"object"},"TeamBinding_LTE4NTM5MTc0":{"deprecated":true,"description":"Deprecated, please ignore.","enum":[true,false],"type":"boolean"},"TeamCollaborator_LTI3MzM1MTYz":{"properties":{"permissions":{"items":{"$ref":"#/components/schemas/CollaboratorPermission_NDg5NTg2ODgy"},"type":"array"},"team":{"$ref":"#/components/schemas/UUID"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","team","permissions"],"type":"object"},"TeamContact_LTI5MTIxODc0":{"properties":{"accent_id":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"email":{"$ref":"#/components/schemas/Email"},"email_unvalidated":{"$ref":"#/components/schemas/Email"},"handle":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"managed_by":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"name":{"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"},"saml_idp":{"type":"string"},"scim_external_id":{"type":"string"},"searchable":{"type":"boolean"},"sso":{"$ref":"#/components/schemas/Sso_LTg1MDM5ODQ3"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"},"user_groups":{"description":"List of user group ids the user is a member of","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["id","type","name","user_groups","searchable"],"type":"object"},"TeamConversationList_OTI3MzY3NzY0":{"description":"Team conversation list","properties":{"conversations":{"items":{"$ref":"#/components/schemas/TeamConversation_LTIwNzgyNTEz"},"type":"array"}},"required":["conversations"],"type":"object"},"TeamConversation_LTIwNzgyNTEz":{"description":"Team conversation data","properties":{"conversation":{"$ref":"#/components/schemas/UUID"},"managed":{"description":"This field MUST NOT be used by clients. It is here only for backwards compatibility of the interface."}},"required":["conversation","managed"],"type":"object"},"TeamDeleteData_ODI5NTU0ODE5":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"type":"object"},"TeamDomainRedirectTag_MjQwMjc1Mjk3":{"enum":["no-registration","none"],"type":"string"},"TeamInviteConfig_MTg4Nzk4NzMz":{"properties":{"domain_redirect":{"$ref":"#/components/schemas/TeamDomainRedirectTag_MjQwMjc1Mjk3"},"sso":{"example":"99db9768-04e3-4b5d-9268-831b6a25c4ab","format":"uuid","type":"string"},"team":{"$ref":"#/components/schemas/UUID"},"team_invite":{"$ref":"#/components/schemas/TeamInviteTag_LTQyNTMyNzA0"}},"required":["team_invite","team"],"type":"object"},"TeamInviteTag_LTQyNTMyNzA0":{"enum":["allowed","not-allowed","team"],"type":"string"},"TeamMemberDeleteData_LTg2OTEyOTI4":{"description":"Data for a team member deletion request in case of binding teams.","properties":{"password":{"description":"The account password to authorise the deletion.","maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"TeamMemberList_Optional_LTM1ODE2MzM0":{"description":"list of team member","properties":{"hasMore":{"$ref":"#/components/schemas/ListType_LTkyMDM4MzA1"},"members":{"description":"the array of team members","items":{"$ref":"#/components/schemas/TeamMember_Optional_NTU0MDcyNzI1"},"type":"array"}},"required":["members","hasMore"],"type":"object"},"TeamMember_Optional_NTU0MDcyNzI1":{"description":"team member data","properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"legalhold_status":{"$ref":"#/components/schemas/UserLegalHoldStatus_LTQ2ODA2NTU5"},"permissions":{"$ref":"#/components/schemas/Permissions_NDE0ODM5NDUx"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user"],"type":"object"},"TeamMembersPage_NzYwNDIxODgx":{"properties":{"hasMore":{"type":"boolean"},"members":{"items":{"$ref":"#/components/schemas/TeamMember_Optional_NTU0MDcyNzI1"},"type":"array"},"pagingState":{"$ref":"#/components/schemas/TeamMembers_PagingState"}},"required":["members","hasMore","pagingState"],"type":"object"},"TeamMembers_PagingState":{"type":"string"},"TeamSearchVisibilityView_Mzg3MzMzMTk3":{"description":"Search visibility value for the team","properties":{"search_visibility":{"$ref":"#/components/schemas/TeamSearchVisibility_LTIzODE2Njk3"}},"required":["search_visibility"],"type":"object"},"TeamSearchVisibility_LTIzODE2Njk3":{"description":"value of visibility","enum":["standard","no-name-outside-team"],"type":"string"},"TeamSize_LTMzMzk2MTk1":{"description":"Team member counts broken down by user type.","properties":{"teamSize":{"description":"Total team members (teamSizeRegulars + teamSizeApps).","exclusiveMinimum":false,"minimum":0,"type":"integer"},"teamSizeApps":{"description":"Number of apps in team.","exclusiveMinimum":false,"minimum":0,"type":"integer"},"teamSizeRegulars":{"description":"Number of regular users in team.","exclusiveMinimum":false,"minimum":0,"type":"integer"}},"required":["teamSizeRegulars","teamSizeApps"],"type":"object"},"TeamUpdateData_LTE0NTM2NTU5":{"properties":{"icon":{"$ref":"#/components/schemas/Icon"},"icon_key":{"maxLength":256,"minLength":1,"type":"string"},"name":{"maxLength":256,"minLength":1,"type":"string"},"splash_screen":{"$ref":"#/components/schemas/Icon"}},"type":"object"},"Team_NDg4MjQwOTIw":{"description":"`binding` is deprecated, and should be ignored. The non-binding teams API is not used (and will not be supported from API version V4 onwards), and `binding` will always be `true`.","properties":{"binding":{"$ref":"#/components/schemas/TeamBinding_LTE4NTM5MTc0"},"creator":{"$ref":"#/components/schemas/UUID"},"icon":{"$ref":"#/components/schemas/Icon"},"icon_key":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"splash_screen":{"$ref":"#/components/schemas/Icon"}},"required":["id","creator","name","icon"],"type":"object"},"Time":{"properties":{"time":{"$ref":"#/components/schemas/UTCTime"}},"required":["time"],"type":"object"},"Token":{"example":"ZXhhbXBsZQo=","type":"string"},"TokenType_NTkyMzk4MjIz":{"enum":["Bearer"],"type":"string"},"Transport_NDk2NzU5NDIy":{"description":"Transport","enum":["GCM","APNS","APNS_SANDBOX","APNS_VOIP","APNS_VOIP_SANDBOX"],"type":"string"},"TurnURI":{"type":"string"},"TurnUsername":{"description":"Username to use for authenticating against the given TURN servers","type":"string"},"TypingStatus_LTg5MzcyNDMy":{"enum":["started","stopped"],"type":"string"},"URI":{"type":"string"},"URIRef_Absolute":{"description":"URL of the invitation link to be sent to the invitee","type":"string"},"UTCTime":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"},"UTCTimeMillis":{"description":"The time when the session was created","example":"2021-05-12T10:52:02.671Z","format":"yyyy-mm-ddThh:MM:ss.qqqZ","type":"string"},"UUID":{"description":"The OAuth client's ID","example":"99db9768-04e3-4b5d-9268-831b6a25c4ab","format":"uuid","type":"string"},"UncheckedPrekeyBundle_LTU1MzQzOTgy":{"properties":{"id":{"maximum":65535,"minimum":0,"type":"integer"},"key":{"type":"string"}},"required":["id","key"],"type":"object"},"UpdateBotPrekeys_LTg3NzYxODg0":{"properties":{"prekeys":{"items":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"type":"array"}},"required":["prekeys"],"type":"object"},"UpdateClient_NzU5MjA4MzI1":{"properties":{"capabilities":{"$ref":"#/components/schemas/ClientCapabilityList"},"label":{"description":"A new name for this client.","type":"string"},"lastkey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"mls_public_keys":{"$ref":"#/components/schemas/MLSPublicKeys"},"prekeys":{"description":"New prekeys for other clients to establish OTR sessions.","items":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"type":"array"}},"type":"object"},"UpdateMeeting_NTExNzYxMTcz":{"description":"Request to update a meeting","properties":{"end_time":{"$ref":"#/components/schemas/UTCTime"},"recurrence":{"$ref":"#/components/schemas/Recurrence_LTQ0OTc0ODE2"},"start_time":{"$ref":"#/components/schemas/UTCTime"},"title":{"maxLength":256,"minLength":1,"type":"string"}},"type":"object"},"UpdateProvider_LTQwMjY4MDgy":{"properties":{"description":{"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"url":{"$ref":"#/components/schemas/HttpsUrl"}},"type":"object"},"UpdateServiceConn_LTQ1OTYwNjIz":{"properties":{"auth_tokens":{"items":{"$ref":"#/components/schemas/ASCII"},"maxItems":2,"minItems":1,"type":"array"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"enabled":{"type":"boolean"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"public_keys":{"items":{"$ref":"#/components/schemas/ServiceKeyPEM"},"maxItems":2,"minItems":1,"type":"array"}},"required":["password"],"type":"object"},"UpdateServiceWhitelist_LTU5MDAwMTIw":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"provider":{"$ref":"#/components/schemas/UUID"},"whitelisted":{"type":"boolean"}},"required":["provider","id","whitelisted"],"type":"object"},"UpdateService_MjAxNzQ2Njkz":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"description":{"maxLength":1024,"minLength":1,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"summary":{"maxLength":128,"minLength":1,"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"maxItems":3,"minItems":1,"type":"array"}},"type":"object"},"UpdateUserGroupChannels_LTIyMjcwMTMx":{"properties":{"channels":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["channels"],"type":"object"},"UpdateUserGroupMembers_LTg1MzQ2NDY3":{"properties":{"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["members"],"type":"object"},"UserClientMap":{"additionalProperties":{"additionalProperties":{"type":"string"},"type":"object"},"type":"object"},"UserClientPrekeyMap":{"additionalProperties":{"additionalProperties":{"properties":{"id":{"maximum":65535,"minimum":0,"type":"integer"},"key":{"type":"string"}},"required":["id","key"],"type":"object"},"type":"object"},"example":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":{"44901fb0712e588f":{"id":1,"key":"pQABAQECoQBYIOjl7hw0D8YRNq..."}}},"type":"object"},"UserClients":{"additionalProperties":{"items":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"type":"array"},"description":"Map of user id to list of client ids.","example":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":["60f85e4b15ad3786","6e323ab31554353b"]},"type":"object"},"UserConnection_LTY3NzU1ODg0":{"properties":{"conversation":{"$ref":"#/components/schemas/UUID"},"from":{"$ref":"#/components/schemas/UUID"},"last_update":{"$ref":"#/components/schemas/UTCTimeMillis"},"qualified_conversation":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_to":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"status":{"$ref":"#/components/schemas/Relation_LTE4OTU5MTk4"},"to":{"$ref":"#/components/schemas/UUID"}},"required":["from","qualified_to","status","last_update"],"type":"object"},"UserGroupAddUsers_LTgzOTYzNzk0":{"properties":{"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["members"],"type":"object"},"UserGroupNameAvailability_LTYzMDE1NTk4":{"properties":{"name_available":{"type":"boolean"}},"required":["name_available"],"type":"object"},"UserGroupPage_UserGroup_Const_LTMxNDg5MDAy":{"description":"This is the last page if it contains fewer rows than requested. There may be 0 rows on a page.","properties":{"page":{"items":{"$ref":"#/components/schemas/UserGroup_Const_NTMzOTAzMzA1"},"type":"array"},"total":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["page","total"],"type":"object"},"UserGroupUpdate_MjUyNTA3Mjgy":{"properties":{"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["name"],"type":"object"},"UserGroup_Const_NTMzOTAzMzA1":{"properties":{"channels":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"},"channelsCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"createdAt":{"$ref":"#/components/schemas/UTCTimeMillis"},"id":{"$ref":"#/components/schemas/UUID"},"managedBy":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"membersCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["id","name","managedBy","createdAt"],"type":"object"},"UserGroup_Identity_NTg4MTY1MjEx":{"properties":{"channels":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"},"channelsCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"createdAt":{"$ref":"#/components/schemas/UTCTimeMillis"},"id":{"$ref":"#/components/schemas/UUID"},"managedBy":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"membersCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["id","name","members","managedBy","createdAt"],"type":"object"},"UserIdList_MzA1MTI1Njgx":{"properties":{"user_ids":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["user_ids"],"type":"object"},"UserLegalHoldStatusResponse_LTQ1MzUxMTE3":{"properties":{"client":{"$ref":"#/components/schemas/IdObject_ClientId_LTM3NjQyODM5"},"last_prekey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"status":{"$ref":"#/components/schemas/UserLegalHoldStatus_LTQ2ODA2NTU5"}},"required":["status"],"type":"object"},"UserLegalHoldStatus_LTQ2ODA2NTU5":{"description":"The state of Legal Hold compliance for the member","enum":["enabled","pending","disabled","no_consent"],"type":"string"},"UserMap_Set_PubClient":{"additionalProperties":{"items":{"$ref":"#/components/schemas/PubClient"},"type":"array","uniqueItems":true},"description":"Map of UserId to (Set PubClient)","example":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":[{"class":"legalhold","id":"d0"}]},"type":"object"},"UserProfile_LTQzMTQxMTE1":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"app":{"$ref":"#/components/schemas/AppInfo_MjgwNTkwOTUz"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"deleted":{"type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"expires_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"handle":{"$ref":"#/components/schemas/Handle"},"id":{"$ref":"#/components/schemas/UUID"},"legalhold_status":{"$ref":"#/components/schemas/UserLegalHoldStatus_LTQ2ODA2NTU5"},"name":{"maxLength":128,"minLength":1,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"searchable":{"type":"boolean"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"},"team":{"$ref":"#/components/schemas/UUID"},"text_status":{"maxLength":256,"minLength":1,"type":"string"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"}},"required":["qualified_id","name","accent_id","legalhold_status"],"type":"object"},"UserSSOId":{"properties":{"scim_external_id":{"type":"string"},"subject":{"type":"string"},"tenant":{"type":"string"}},"type":"object"},"UserType_LTU1OTU4OTM5":{"enum":["regular","app","bot"],"type":"string"},"UserUpdate_MjQ4NTEwOTQz":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"name":{"maxLength":128,"minLength":1,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"text_status":{"maxLength":256,"minLength":1,"type":"string"}},"type":"object"},"User_NjA4OTQwMTQ4":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"deleted":{"type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"email_unvalidated":{"$ref":"#/components/schemas/Email"},"expires_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"handle":{"$ref":"#/components/schemas/Handle"},"id":{"$ref":"#/components/schemas/UUID"},"locale":{"$ref":"#/components/schemas/Locale"},"managed_by":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"name":{"maxLength":128,"minLength":1,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"searchable":{"type":"boolean"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"sso_id":{"$ref":"#/components/schemas/UserSSOId"},"status":{"$ref":"#/components/schemas/AccountStatus_NzkzNDU1ODU5"},"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"},"team":{"$ref":"#/components/schemas/UUID"},"text_status":{"maxLength":256,"minLength":1,"type":"string"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"}},"required":["qualified_id","type","name","accent_id","status","locale"],"type":"object"},"VerificationAction_LTU0MzYxNzUz":{"enum":["create_scim_token","login","delete_team"],"type":"string"},"VerifyDeleteUser_Njc1NDQ1MDIy":{"description":"Data for verifying an account deletion.","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"}},"required":["key","code"],"type":"object"},"VersionInfo_NTEzMTgzNDQ0":{"example":{"development":[16],"domain":"example.com","federation":false,"supported":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]},"properties":{"development":{"items":{"$ref":"#/components/schemas/VersionNumber_Njk2NzI5Njk1"},"type":"array"},"domain":{"$ref":"#/components/schemas/Domain"},"federation":{"type":"boolean"},"supported":{"items":{"$ref":"#/components/schemas/VersionNumber_Njk2NzI5Njk1"},"type":"array"}},"required":["supported","development","federation","domain"],"type":"object"},"VersionNumber_Njk2NzI5Njk1":{"enum":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16],"type":"integer"},"ViewLegalHoldServiceInfo_LTc3NjI2MzQ3":{"properties":{"auth_token":{"$ref":"#/components/schemas/ASCII"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"fingerprint":{"$ref":"#/components/schemas/Fingerprint"},"public_key":{"$ref":"#/components/schemas/ServiceKeyPEM"},"team_id":{"$ref":"#/components/schemas/UUID"}},"required":["team_id","base_url","fingerprint","auth_token","public_key"],"type":"object"},"ViewLegalHoldService_LTE3MzQzNDkw":{"properties":{"settings":{"$ref":"#/components/schemas/ViewLegalHoldServiceInfo_LTc3NjI2MzQ3"},"status":{"$ref":"#/components/schemas/LHServiceStatus_ODc3NzE0Mjg3"}},"required":["status"],"type":"object"},"WireIdPAPIVersion_NTEyMzIwNTU3":{"enum":["WireIdPAPIV1","WireIdPAPIV2"],"type":"string"},"WireIdP_ODMzOTExMzYw":{"properties":{"apiVersion":{"enum":["WireIdPAPIV1","WireIdPAPIV2"],"type":"string"},"domain":{"type":"string"},"handle":{"type":"string"},"oldIssuers":{"items":{"$ref":"#/components/schemas/URI"},"type":"array"},"replacedBy":{"type":"string"},"team":{"$ref":"#/components/schemas/UUID"}},"required":["team","apiVersion","oldIssuers","replacedBy","handle","domain"],"type":"object"},"v2_ConversationAccessData_MjMxMTI5ODc3":{"properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"$ref":"#/components/schemas/AccessRoleLegacy_LTYwOTAxMDI1"},"access_role_v2":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"}},"required":["access"],"type":"object"},"v2_OwnConversation_NDU4NDc3MDgz":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"$ref":"#/components/schemas/AccessRoleLegacy_LTYwOTAxMDI1"},"access_role_v2":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/EpochTimestamp"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","members","group_id","epoch","epoch_timestamp","cipher_suite"],"type":"object"},"v3_OwnConversation_NDU4NDc3MDgzV3":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/EpochTimestamp"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch","epoch_timestamp","cipher_suite"],"type":"object"},"v6_OwnConversation_NDU4NDc3MDgzV6":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"},"v9_OwnConversation_NDU4NDc3MDgz":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"}},"securitySchemes":{"ZAuth":{"description":"Must be a token retrieved by calling 'POST /login' or 'POST /access'. It must be presented in this format: 'Bearer \\'.","in":"header","name":"Authorization","type":"apiKey"}}},"info":{"description":"## Authentication / Authorization\n\nThe end-points in this API support differing authorization protocols:\nsome are unauthenticated (`/api-version`, `/login`), some require\n[zauth](), and some support both [zauth]() and [oauth]().\n\nThe end-points that require zauth are labelled so in the description\nbelow. The end-points that support oauth as an alternative to zauth\nhave the required oauth scopes listed in the same description.\n\nFuther reading:\n- https://docs.wire.com/developer/reference/oauth.html\n- https://github.com/wireapp/wire-server/blob/develop/libs/wire-api/src/Wire/API/Routes/Public.hs (search for HasSwagger instances)\n- `curl https://staging-nginz-https.zinfra.io/v4/api/swagger.json | jq '.security, .securityDefinitions`\n\n### SSO Endpoints\n\n#### Overview\n\n`/sso/metadata` will be requested by the IdPs to learn how to talk to wire.\n\n`/sso/initiate-login`, `/sso/finalize-login` are for the SAML authentication handshake performed by a user in order to log into wire. They are not exactly standard in their details: they may return HTML or XML; redirect to error URLs instead of throwing errors, etc.\n\n`/identity-providers` end-points are for use in the team settings page when IdPs are registered. They talk json.\n\n\n#### Configuring IdPs\n\nIdPs usually allow you to copy the metadata into your clipboard. That should contain all the details you need to post the idp in your team under `/identity-providers`. (Team id is derived from the authorization credentials of the request.)\n\n##### okta.com\n\nOkta will ask you to provide two URLs when you set it up for talking to wireapp:\n\n1. The `Single sign on URL`. This is the end-point that accepts the user's credentials after successful authentication against the IdP. Choose `/sso/finalize-login` with schema and hostname of the wire server you are configuring.\n\n2. The `Audience URI`. You can find this in the metadata returned by the `/sso/metadata` end-point. It is the contents of the `md:OrganizationURL` element.\n\n##### centrify.com\n\nCentrify allows you to upload the metadata xml document that you get from the `/sso/metadata` end-point. You can also enter the metadata url and have centrify retrieve the xml, but to guarantee integrity of the setup, the metadata should be copied from the team settings page and pasted into the centrify setup page without any URL indirections.\n\n## Federation errors\n\nEndpoints involving federated calls to other domains can return some extra failure responses, common to all endpoints. Instead of listing them as possible responses for each endpoint, we document them here.\n\nFor errors that are more likely to be transient, we suggest clients to retry whatever request resulted in the error. Transient errors are indicated explicitly below.\n\n**Note**: when a failure occurs as a result of making a federated RPC to another backend, the error response contains the following extra fields:\n\n - `type`: \"federation\" (just the literal string in quotes, which can be used as an error type identifier when parsing errors)\n - `domain`: the target backend of the RPC that failed;\n - `path`: the path of the RPC that failed.\n\n### Domain errors\n\nErrors in this category result from trying to communicate with a backend that is considered non-existent or invalid. They can result from invalid user input or client issues, but they can also be a symptom of misconfiguration in one or multiple backends. These errors have a 4xx status code.\n\n - **Remote backend not found** (status: 422, label: `invalid-domain`): This backend attempted to contact a backend which does not exist or is not properly configured. For the most part, clients can consider this error equivalent to a domain not existing, although it should be noted that certain mistakes in the DNS configuration on a remote backend can lead to the backend not being recognized, and hence to this error. It is therefore not advisable to take any destructive action upon encountering this error, such as deleting remote users from conversations.\n - **Federation denied locally** (status: 400, label: `federation-denied`): This backend attempted an RPC to a non-whitelisted backend. Similar considerations as for the previous error apply.\n - **Federation not enabled** (status: 400, label: `federation-not-enabled`): Federation has not been configured for this backend. This will happen if a federation-aware client tries to talk to a backend for which federation is disabled, or if federation was disabled on the backend after reaching a federation-specific state (e.g. conversations with remote users). There is no way to cleanly recover from these errors at this point.\n\n### Local federation errors\n\nAn error in this category likely indicates an issue with the configuration of federation on the local backend. Possibly transient errors are indicated explicitly below. All these errors have a 500 status code.\n\n - **Federation unavailable** (status: 500, label: `federation-not-available`): Federation is configured for this backend, but the local federator cannot be reached. This can be transient, so clients should retry the request.\n - **Federation not implemented** (status: 422, label: `federation-not-implemented`): Federated behaviour for a certain endpoint is not yet implemented.\n - **Federator discovery failed** (status: 400, label: `discovery-failure`): A DNS error occurred during discovery of a remote backend. This can be transient, so clients should retry the request.\n - **Local federation error** (status: 500, label: `federation-local-error`): An error occurred in the communication between this backend and its local federator. These errors are most likely caused by bugs in the backend, and should be reported as such.\n\n### Remote federation errors\n\nErrors in this category are returned in case of communication issues between the local backend and a remote one, or if the remote side encountered an error while processing an RPC. Some errors in this category might be caused by incorrect client behaviour, wrong user input, or incorrect certificate configuration. Possibly transient errors are indicated explicitly. We use non-standard 5xx status codes for these errors.\n\n - **HTTP2 error** (status: 533, label: `federation-http2-error`): The current federator encountered an error when making an HTTP2 request to a remote one. Check the error message for more details.\n - **Connection refused** (status: 521, label: `federation-connection-refused`): The local federator could not connect to a remote one. This could be transient, so clients should retry the request.\n - **TLS failure**: (status: 525, label: `federation-tls-error`): An error occurred during the TLS handshake between the local federator and a remote one. This is most likely due to an issue with the certificate on the remote end.\n - **Remote federation error** (status: 533, label: `federation-remote-error`): The remote backend could not process a request coming from this backend. Check the error message for more details.\n - **Version negotiation error** (status: 533, label: `federation-version-error`): The remote backend returned invalid version information.\n\n### Backend compatibility errors\n\nAn error in this category will be returned when this backend makes an invalid or unsupported RPC to another backend. This can indicate some incompatibility between backends or a backend bug. These errors are unlikely to be transient, so retrying requests is *not* advised.\n\n - **Version mismatch** (status: 531, label: `federation-version-mismatch`): A remote backend is running an unsupported version of the federator.\n - **Invalid content type** (status: 533, label: `federation-invalid-content-type`): An RPC to another backend returned with an invalid content type.\n - **Unsupported content type** (status: 533, label: `federation-unsupported-content-type`): An RPC to another backend returned with an unsupported content type.\n","title":"Wire-Server API","version":""},"openapi":"3.0.0","paths":{"/access":{"post":{"description":" [internal route ID: \"access\"]\n\nYou can provide only a cookie or a cookie and token. Every other combination is invalid. Access tokens can be given as query parameter or authorisation header, with the latter being preferred.","operationId":"access","parameters":[{"in":"query","name":"client_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)"}},"summary":"Obtain an access tokens for a cookie"}},"/access/logout":{"post":{"description":" [internal route ID: \"logout\"]\n\nCalling this endpoint will effectively revoke the given cookie and subsequent calls to /access with the same cookie will result in a 403.","operationId":"logout","responses":{"200":{"description":"Logout"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)"}},"summary":"Log out in order to remove a cookie from the server"}},"/access/self/email":{"put":{"description":" [internal route ID: \"change-self-email\"]\n\n","operationId":"change-self-email","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/EmailUpdate_NjQ5MDg1OTY0"}}},"required":true},"responses":{"202":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"Update accepted and pending activation of the new email"},"204":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"No update, current and new email address are the same\n\nEmail address activated"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid e-mail address. (label: `invalid-email`) or `body`"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","blacklisted-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}},"summary":"Change your email address"}},"/activate":{"get":{"description":" [internal route ID: \"get-activate\"]\n\nSee also 'POST /activate' which has a larger feature set.","operationId":"get-activate","parameters":[{"description":"Activation key","in":"query","name":"key","required":true,"schema":{"type":"string"}},{"description":"Activation code","in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}}},"description":"Activation successful.\n\nActivation successful. (Dry run)\n\nActivation successful."},"204":{"description":"A recent activation was already successful."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-phone","message":"Invalid mobile phone number"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-phone","invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `code` or `key`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"Invalid activation code"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}},"summary":"Activate (i.e. confirm) an email address."},"post":{"description":" [internal route ID: \"post-activate\"]\n\nActivation only succeeds once and the number of failed attempts for a valid key is limited.","operationId":"post-activate","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Activate_MzUzNzIxODUw"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}}},"description":"Activation successful.\n\nActivation successful. (Dry run)\n\nActivation successful."},"204":{"description":"A recent activation was already successful."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-phone","message":"Invalid mobile phone number"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-phone","invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"Invalid activation code"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}},"summary":"Activate (i.e. confirm) an email address."}},"/activate/send":{"post":{"description":" [internal route ID: \"post-activate-send\"]\n\n","operationId":"post-activate-send","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SendActivationCode_LTgyNDAxNzEy"}}},"required":true},"responses":{"200":{"description":"Activation code sent."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"blacklisted-email","message":"The given e-mail address has been blacklisted due to a permanent bounce or a complaint."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["blacklisted-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"},"451":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":451,"label":"domain-blocked-for-registration","message":"[Customer extension] The email domain has been blocked for Wire users. Please contact your IT department."},"properties":{"code":{"enum":[451],"type":"integer"},"label":{"enum":["domain-blocked-for-registration"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"[Customer extension] The email domain has been blocked for Wire users. Please contact your IT department. (label: `domain-blocked-for-registration`)"}},"summary":"Send (or resend) an email activation code."}},"/api-version":{"get":{"description":" [internal route ID: \"get-version\"]\n\n","operationId":"get-version","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/VersionInfo_NTEzMTgzNDQ0"}}},"description":""}}}},"/assets":{"post":{"description":" [internal route ID: \"assets-upload\"]\n\n

Construct the request as multipart/mixed; set header Content-Type: multipart/mixed; boundary=<boundary>.

Use exactly two parts in this order:

  1. application/json metadata (AssetSettings)
  2. application/octet-stream asset bytes

Each part must include Content-Type and Content-Length; the second part may include Content-MD5. Use CRLF between headers and bodies.

When asset audit logging is enabled, the JSON metadata must include:

  • convId: object { id: UUID, domain: String } (qualified conversation ID)
  • filename: String
  • filetype: String MIME type (e.g. image/png, application/pdf)

Optional metadata: public (Bool, default false), retention (one of eternal, persistent, volatile, eternal-infrequent_access, expiring).

For profile pictures or team icons without a conversation, set convId.id to 00000000-0000-0000-0000-000000000000 and convId.domain to the tenant’s domain; use any reasonable filename.

Note: the server treats the asset bytes as application/octet-stream; filetype is used for auditing only.

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

--frontier
Content-Type: application/json
Content-Length: 191

{\"public\":false,\"retention\":\"volatile\",\"convId\":{\"id\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"domain\":\"example.com\"},\"filename\":\"report.pdf\",\"filetype\":\"application/pdf\"}
--frontier
Content-Type: application/octet-stream
Content-Length: 11

Hello Audit
--frontier--
","operationId":"assets-upload","requestBody":{"content":{"multipart/mixed":{"schema":{"$ref":"#/components/schemas/AssetSource"}}},"description":"A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server."},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}}},"description":"Asset posted","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"incomplete-body","message":"HTTP content-length header does not match body size"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["incomplete-body","invalid-length"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nHTTP content-length header does not match body size (label: `incomplete-body`)\n\nInvalid content length (label: `invalid-length`)"},"413":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"client-error","message":"Asset too large"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["client-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Asset too large (label: `client-error`)"}},"summary":"Upload an asset"}},"/assets/{key_domain}/{key}":{"delete":{"description":" [internal route ID: \"assets-delete\"]\n\n**Note**: only local assets can be deleted.","operationId":"assets-delete","parameters":[{"in":"path","name":"key_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key_domain` or `key` not found\n\nAsset not found (label: `not-found`)"}},"summary":"Delete an asset"},"get":{"description":" [internal route ID: \"assets-download\"]\n\n**Note**: local assets result in a redirect, while remote assets are streamed directly.","operationId":"assets-download","parameters":[{"in":"path","name":"key_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"key","required":true,"schema":{"type":"string"}},{"in":"header","name":"Asset-Token","required":false,"schema":{"type":"string"}},{"in":"query","name":"asset_token","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset returned directly with content type `application/octet-stream`"},"302":{"description":"Asset found","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key_domain` or `key` or Asset not found (label: `not-found`)\n\nAsset not found (label: `not-found`)"}},"summary":"Download an asset"}},"/assets/{key}/token":{"delete":{"description":" [internal route ID: \"tokens-delete\"]\n\n**Note**: deleting the token makes the asset public.","operationId":"tokens-delete","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset token deleted"}},"summary":"Delete an asset token"},"post":{"description":" [internal route ID: \"tokens-renew\"]\n\n","operationId":"tokens-renew","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewAssetToken_NTAwMDQwODYy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` not found\n\nAsset not found (label: `not-found`)"}},"summary":"Renew an asset token"}},"/await":{"get":{"description":" [internal route ID: \"await-notifications\"]\n\n","externalDocs":{"description":"RFC 6455","url":"https://datatracker.ietf.org/doc/html/rfc6455"},"operationId":"await-notifications","parameters":[{"description":"Client ID","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"101":{"description":"Connection upgraded."},"426":{"description":"Upgrade required."}},"summary":"Establish websocket connection"}},"/bot/assets":{"post":{"description":" [internal route ID: (\"assets-upload-v3\", bot)]\n\n

Construct the request as multipart/mixed; set header Content-Type: multipart/mixed; boundary=<boundary>.

Use exactly two parts in this order:

  1. application/json metadata (AssetSettings)
  2. application/octet-stream asset bytes

Each part must include Content-Type and Content-Length; the second part may include Content-MD5. Use CRLF between headers and bodies.

When asset audit logging is enabled, the JSON metadata must include:

  • convId: object { id: UUID, domain: String } (qualified conversation ID)
  • filename: String
  • filetype: String MIME type (e.g. image/png, application/pdf)

Optional metadata: public (Bool, default false), retention (one of eternal, persistent, volatile, eternal-infrequent_access, expiring).

For profile pictures or team icons without a conversation, set convId.id to 00000000-0000-0000-0000-000000000000 and convId.domain to the tenant’s domain; use any reasonable filename.

Note: the server treats the asset bytes as application/octet-stream; filetype is used for auditing only.

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

--frontier
Content-Type: application/json
Content-Length: 191

{\"public\":false,\"retention\":\"volatile\",\"convId\":{\"id\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"domain\":\"example.com\"},\"filename\":\"report.pdf\",\"filetype\":\"application/pdf\"}
--frontier
Content-Type: application/octet-stream
Content-Length: 11

Hello Audit
--frontier--
","operationId":"assets-upload-v3_bot","requestBody":{"content":{"multipart/mixed":{"schema":{"$ref":"#/components/schemas/AssetSource"}}},"description":"A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server."},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}}},"description":"Asset posted","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"incomplete-body","message":"HTTP content-length header does not match body size"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["incomplete-body","invalid-length"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nHTTP content-length header does not match body size (label: `incomplete-body`)\n\nInvalid content length (label: `invalid-length`)"},"413":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"client-error","message":"Asset too large"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["client-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Asset too large (label: `client-error`)"}},"summary":"Upload an asset"}},"/bot/assets/{key}":{"delete":{"description":" [internal route ID: (\"assets-delete-v3\", bot)]\n\n","operationId":"assets-delete-v3_bot","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` not found\n\nAsset not found (label: `not-found`)"}},"summary":"Delete an asset"},"get":{"description":" [internal route ID: (\"assets-download-v3\", bot)]\n\n","operationId":"assets-download-v3_bot","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}},{"in":"header","name":"Asset-Token","required":false,"schema":{"type":"string"}},{"in":"query","name":"asset_token","required":false,"schema":{"type":"string"}}],"responses":{"302":{"description":"Asset found","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` or Asset not found (label: `not-found`)"}},"summary":"Download an asset"}},"/bot/client":{"get":{"description":" [internal route ID: \"bot-get-client\"]\n\n","operationId":"bot-get-client","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}}},"description":"Client found"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"client-not-found","message":"Client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["client-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"client-not-found","message":"Client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["client-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Client not found (label: `client-not-found`)\n\nClient not found (label: `client-not-found`)"}},"summary":"Get client for bot"}},"/bot/client/prekeys":{"get":{"description":" [internal route ID: \"bot-list-prekeys\"]\n\n","operationId":"bot-list-prekeys","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"maximum":65535,"minimum":0,"type":"integer"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"List prekeys for bot"},"post":{"description":" [internal route ID: \"bot-update-prekeys\"]\n\n","operationId":"bot-update-prekeys","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateBotPrekeys_LTg3NzYxODg0"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"client-not-found","message":"Client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["client-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Client not found (label: `client-not-found`)"}},"summary":"Update prekeys for bot"}},"/bot/conversation":{"get":{"description":" [internal route ID: \"get-bot-conversation\"]\n\n","operationId":"get-bot-conversation","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/BotConvView_LTYzMjIzMjQz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)"}}}},"/bot/conversations/{conv}":{"post":{"description":" [internal route ID: \"add-bot\"]\n\n","operationId":"add-bot","parameters":[{"in":"path","name":"conv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AddBot_NjI0ODkyODk3"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddBotResponse_ODA5MzA2NTA1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AddBotResponse_ODA5MzA2NTA1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"service-disabled","message":"The desired service is currently disabled."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["service-disabled","too-many-members","invalid-conversation","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The desired service is currently disabled. (label: `service-disabled`)\n\nMaximum number of members per conversation reached. (label: `too-many-members`)\n\nThe operation is not allowed in this conversation. (label: `invalid-conversation`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Add bot"}},"/bot/conversations/{conv}/{bot}":{"delete":{"description":" [internal route ID: \"remove-bot\"]\n\n","operationId":"remove-bot","parameters":[{"in":"path","name":"conv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"bot","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveBotResponse_LTUxNTQ4MDEy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RemoveBotResponse_LTUxNTQ4MDEy"}}},"description":"User found"},"204":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-conversation","message":"The operation is not allowed in this conversation."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-conversation","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The operation is not allowed in this conversation. (label: `invalid-conversation`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Remove bot"}},"/bot/messages":{"post":{"description":" [internal route ID: \"post-bot-message-unqualified\"]\n\n","operationId":"post-bot-message-unqualified","parameters":[{"in":"query","name":"ignore_missing","required":false,"schema":{"type":"string"}},{"in":"query","name":"report_missing","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Message sent"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation not found (label: `no-conversation`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Missing clients"}}}},"/bot/self":{"delete":{"description":" [internal route ID: \"bot-delete-self\"]\n\n","operationId":"bot-delete-self","responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-bot","message":"The targeted user is not a bot."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-bot","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The targeted user is not a bot. (label: `invalid-bot`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Delete self"},"get":{"description":" [internal route ID: \"bot-get-self\"]\n\n","operationId":"bot-get-self","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"User not found (label: `not-found`)"}},"summary":"Get self"}},"/bot/users":{"get":{"description":" [internal route ID: \"bot-list-users\"]\n\n","operationId":"bot-list-users","parameters":[{"in":"query","name":"ids","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/BotUserView_LTE2MTkwMTcw"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"List users"}},"/bot/users/prekeys":{"post":{"description":" [internal route ID: \"bot-claim-users-prekeys\"]\n\n","operationId":"bot-claim-users-prekeys","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserClients"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserClientPrekeyMap"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","missing-legalhold-consent-old-clients","too-many-clients","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nToo many clients (label: `too-many-clients`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Claim users prekeys"}},"/bot/users/{user}/clients":{"get":{"description":" [internal route ID: \"bot-get-user-clients\"]\n\n","operationId":"bot-get-user-clients","parameters":[{"in":"path","name":"user","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/PubClient"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"Get user clients"}},"/broadcast/otr/messages":{"post":{"description":" [internal route ID: \"post-otr-broadcast-unqualified\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts two query params:\n- `ignore_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are ignored.\n - When 'false' all missing clients are reported.\n - When comma separated list of user-ids, only clients for listed users are ignored.\n- `report_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are reported.\n - When 'false' all missing clients are ignored.\n - When comma separated list of user-ids, only clients for listed users are reported.\n\nApart from these, the request body also accepts `report_missing` which can only be a list of user ids and behaves the same way as the query parameter.\n\nAll three of these should be considered mutually exclusive. The server however does not error if more than one is specified, it reads them in this order of precedence:\n- `report_missing` in the request body has highest precedence.\n- `ignore_missing` in the query param is the next.\n- `report_missing` in the query param has the lowest precedence.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-otr-broadcast-unqualified","parameters":[{"in":"query","name":"ignore_missing","required":false,"schema":{"type":"string"}},{"in":"query","name":"report_missing","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}},"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Message sent"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"too-many-users-to-broadcast","message":"Too many users to fan out the broadcast event to"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["too-many-users-to-broadcast"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `report_missing` or `ignore_missing`\n\nToo many users to fan out the broadcast event to (label: `too-many-users-to-broadcast`)"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","non-binding-team","no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nNot a member of a binding team (label: `non-binding-team`)\n\nTeam not found (label: `no-team`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Missing clients"}},"summary":"Broadcast an encrypted message to all team members and all contacts (accepts JSON or Protobuf)"}},"/broadcast/proteus/messages":{"post":{"description":" [internal route ID: \"post-proteus-broadcast\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts `client_mismatch_strategy` in the body. It can have these values:\n- `report_all`: When set, the message is not sent if any clients are missing. The missing clients are reported in the response.\n- `ignore_all`: When set, no checks about missing clients are carried out.\n- `report_only`: Takes a list of qualified UserIDs. If any clients of the listed users are missing, the message is not sent. The missing clients are reported in the response.\n- `ignore_only`: Takes a list of qualified UserIDs. If any clients of the non-listed users are missing, the message is not sent. The missing clients are reported in the response.\n\nThe sending of messages in a federated conversation could theoretically fail partially. To make this case unlikely, the backend first gets a list of clients from all the involved backends and then tries to send a message. So, if any backend is down, the message is not propagated to anyone. But the actual message fan out to multiple backends could still fail partially. This type of failure is reported as a 201, the clients for which the message sending failed are part of the response body.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-proteus-broadcast","requestBody":{"content":{"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/QualifiedNewOtrMessage"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Message sent"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"too-many-users-to-broadcast","message":"Too many users to fan out the broadcast event to"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["too-many-users-to-broadcast"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nToo many users to fan out the broadcast event to (label: `too-many-users-to-broadcast`)"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","non-binding-team","no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nNot a member of a binding team (label: `non-binding-team`)\n\nTeam not found (label: `no-team`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Missing clients"}},"summary":"Post an encrypted message to all team members and all contacts (accepts only Protobuf)"}},"/calls/config/v2":{"get":{"description":" [internal route ID: \"get-calls-config-v2\"]\n\n","operationId":"get-calls-config-v2","parameters":[{"description":"Limit resulting list. Allowed values [1..10]","in":"query","name":"limit","required":false,"schema":{"maximum":10,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RTCConfiguration_LTIwOTc4OTk0"}}},"description":""}},"summary":"Retrieve all TURN server addresses and credentials. Clients are expected to do a DNS lookup to resolve the IP addresses of the given hostnames "}},"/clients":{"get":{"description":" [internal route ID: \"list-clients\"]\n\n","operationId":"list-clients","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"},"type":"array"}}},"description":"List of clients"}},"summary":"List the registered clients"},"post":{"description":" [internal route ID: \"add-client\"]\n\n","operationId":"add-client","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewClient_ODg1NjY4Njgy"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}}},"description":"Client registered","headers":{"Location":{"description":"Client ID","schema":{"type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"bad-request","message":"Malformed prekeys uploaded"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["bad-request"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMalformed prekeys uploaded (label: `bad-request`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed","missing-auth","too-many-clients"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nRe-authentication via password required (label: `missing-auth`)\n\nToo many clients (label: `too-many-clients`)"}},"summary":"Register a new client"}},"/clients/{cid}/access-token":{"post":{"description":" [internal route ID: \"create-access-token\"]\n\nCreate an JWT DPoP access token for the client CSR, given a JWT DPoP proof, specified in the `DPoP` header. The access token will be returned in the JSON response body as a JWT DPoP token.","operationId":"create-access-token","parameters":[{"description":"ClientId","in":"path","name":"cid","required":true,"schema":{"type":"string"}},{"in":"header","name":"DPoP","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DPoPAccessTokenResponse_LTgyODU5MDE3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DPoPAccessTokenResponse_LTgyODU5MDE3"}}},"description":"Access token created","headers":{"Cache-Control":{"schema":{"type":"string"}}}}},"summary":"Create a JWT DPoP access token"}},"/clients/{client}":{"delete":{"description":" [internal route ID: \"delete-client\"]\n\n","operationId":"delete-client","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RmClient_MTQ5OTI2MDY3"}}},"required":true},"responses":{"200":{"description":"Client deleted"}},"summary":"Delete an existing client"},"get":{"description":" [internal route ID: \"get-client\"]\n\n","operationId":"get-client","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}}},"description":"Client found"},"404":{"description":"`client` or Client not found(**Note**: This error has an empty body for legacy reasons)"}},"summary":"Get a registered client by ID"},"put":{"description":" [internal route ID: \"update-client\"]\n\n","operationId":"update-client","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateClient_NzU5MjA4MzI1"}}},"required":true},"responses":{"200":{"description":"Client updated"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-duplicate-public-key","message":"MLS public key for the given signature scheme already exists"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-duplicate-public-key","bad-request"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMLS public key for the given signature scheme already exists (label: `mls-duplicate-public-key`)\n\nMalformed prekeys uploaded (label: `bad-request`)"}},"summary":"Update a registered client"}},"/clients/{client}/capabilities":{"get":{"description":" [internal route ID: \"get-client-capabilities\"]\n\n","operationId":"get-client-capabilities","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientCapabilityList"}}},"description":""}},"summary":"Read back what the client has been posting about itself"}},"/clients/{client}/nonce":{"get":{"description":" [internal route ID: \"get-nonce\"]\n\nGet a new nonce for a client CSR, specified in the response header `Replay-Nonce` as a uuidv4 in base64url encoding.","operationId":"get-nonce","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content","headers":{"Cache-Control":{"schema":{"type":"string"}},"Replay-Nonce":{"schema":{"type":"string"}}}}},"summary":"Get a new nonce for a client CSR"},"head":{"description":" [internal route ID: \"head-nonce\"]\n\nGet a new nonce for a client CSR, specified in the response header `Replay-Nonce` as a uuidv4 in base64url encoding.","operationId":"head-nonce","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"No Content","headers":{"Cache-Control":{"schema":{"type":"string"}},"Replay-Nonce":{"schema":{"type":"string"}}}}},"summary":"Get a new nonce for a client CSR"}},"/clients/{client}/prekeys":{"get":{"description":" [internal route ID: \"get-client-prekeys\"]\n\n","operationId":"get-client-prekeys","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"maximum":65535,"minimum":0,"type":"integer"},"type":"array"}}},"description":""}},"summary":"List the remaining prekey IDs of a client"}},"/connections/{uid_domain}/{uid}":{"get":{"description":" [internal route ID: \"get-connection\"]\n\n","operationId":"get-connection","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection found"},"404":{"description":"`uid_domain` or `uid` or Connection not found(**Note**: This error has an empty body for legacy reasons)"}},"summary":"Get an existing connection to another user (local or remote)"},"post":{"description":" [internal route ID: \"create-connection\"]\n\nYou can have no more than 1000 connections in accepted or sent state","operationId":"create-connection","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection existed"},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection was created"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-user","message":"Invalid user"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-user"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid user (label: `invalid-user`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-identity","message":"The user has no verified email"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-identity","connection-limit","missing-legalhold-consent","missing-legalhold-consent-old-clients"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The user has no verified email (label: `no-identity`)\n\nToo many sent/accepted connections (label: `connection-limit`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)"}},"summary":"Create a connection to another user"},"put":{"description":" [internal route ID: \"update-connection\"]\n\n","operationId":"update-connection","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConnectionUpdate_LTU3MTA1OTA5"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection updated"},"204":{"description":"Connection unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-user","message":"Invalid user"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-user"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid user (label: `invalid-user`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-identity","message":"The user has no verified email"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-identity","bad-conn-update","not-connected","connection-limit","missing-legalhold-consent","missing-legalhold-consent-old-clients"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The user has no verified email (label: `no-identity`)\n\nInvalid status transition (label: `bad-conn-update`)\n\nUsers are not connected (label: `not-connected`)\n\nToo many sent/accepted connections (label: `connection-limit`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)"}},"summary":"Update a connection to another user"}},"/conversations":{"post":{"description":" [internal route ID: \"create-group-conversation\"]\n\nThis returns 201 when a new conversation is created, and 200 when the conversation already existed","operationId":"create-group-conversation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewConv_LTgzNTk1NDQx"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGroupConversation_LTE2NzQxMDI0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateGroupConversation_LTE2NzQxMDI0"}}},"description":"Conversation created","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"history-not-supported","message":"Shared history is not supported on this conversation"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["history-not-supported","mls-not-enabled","non-empty-member-list"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nShared history is not supported on this conversation (label: `history-not-supported`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nAttempting to add group members outside MLS (label: `non-empty-member-list`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"channels-not-enabled","message":"The channels feature is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["channels-not-enabled","not-mls-conversation","missing-legalhold-consent","operation-denied","no-team-member","not-connected","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The channels feature is not enabled for this team (label: `channels-not-enabled`)\n\nThis operation requires an MLS conversation (label: `not-mls-conversation`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nUsers are not connected (label: `not-connected`)\n\nConversation access denied (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Create a new conversation"}},"/conversations/code-check":{"post":{"description":" [internal route ID: \"code-check\"]\n\nIf the guest links team feature is disabled, this will fail with 404 CodeNotFound.Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/join` which responds with 409 GuestLinksDisabled if guest links are disabled.","operationId":"code-check","parameters":[{"in":"header","name":"X-Forwarded-For","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCode_Mjg3OTI1NTMx"}}},"required":true},"responses":{"200":{"description":"Valid"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-conversation-password","message":"Invalid conversation password"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-conversation-password"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid conversation password (label: `invalid-conversation-password`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"}},"summary":"Check validity of a conversation code."}},"/conversations/join":{"get":{"description":" [internal route ID: \"get-conversation-by-reusable-code\"]\n\n","operationId":"get-conversation-by-reusable-code","parameters":[{"in":"query","name":"key","required":true,"schema":{"type":"string"}},{"in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCoverView_LTMwNDkxMTA1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","access-denied","invalid-conversation-password"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid conversation password (label: `invalid-conversation-password`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"guest-links-disabled","message":"The guest link feature is disabled and all guest links have been revoked"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}},"summary":"Get limited conversation information by key/code pair"},"post":{"description":" [internal route ID: \"join-conversation-by-code-unqualified\"]\n\nIf the guest links team feature is disabled, this will fail with 409 GuestLinksDisabled.Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/code-check` which responds with 404 CodeNotFound if guest links are disabled.","operationId":"join-conversation-by-code-unqualified","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/JoinConversationByCode_NjgzMzM4Mjg5"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation joined"},"204":{"description":"Conversation unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"too-many-members","message":"Maximum number of members per conversation reached"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["too-many-members","no-team-member","invalid-op","access-denied","invalid-conversation-password"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Maximum number of members per conversation reached (label: `too-many-members`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid conversation password (label: `invalid-conversation-password`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"guest-links-disabled","message":"The guest link feature is disabled and all guest links have been revoked"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}},"summary":"Join a conversation using a reusable code"}},"/conversations/list":{"post":{"description":" [internal route ID: \"list-conversations\"]\n\n","operationId":"list-conversations","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ListConversations_MjkxMTIwODMz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationsResponse_NzgwNjAxNjQz"}}},"description":""}},"summary":"Get conversation metadata for a list of conversation ids"}},"/conversations/list-ids":{"post":{"description":" [internal route ID: \"list-conversation-ids\"]\n\nThe IDs returned by this endpoint are paginated. To get the first page, make a call with the `paging_state` field set to `null` (or omitted). Whenever the `has_more` field of the response is set to `true`, more results are available, and they can be obtained by calling the endpoint again, but this time passing the value of `paging_state` returned by the previous call. One can continue in this fashion until all results are returned, which is indicated by `has_more` being `false`. Note that `paging_state` should be considered an opaque token. It should not be inspected, or stored, or reused across multiple unrelated invocations of the endpoint.","operationId":"list-conversation-ids","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetMuliTabPgRqs_ConvrIdLcOm10_ODU0NjQ1NDMz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MultiTabePg_ConvrsIdqf_cLORm_Q_Mzg0MjYzNzM0"}}},"description":""}},"summary":"Get all conversation IDs."}},"/conversations/mls-self":{"get":{"description":" [internal route ID: \"get-mls-self-conversation\"]\n\n","operationId":"get-mls-self-conversation","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v9_OwnConversation_NDU4NDc3MDgz"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v9_OwnConversation_NDU4NDc3MDgz"}}},"description":"The MLS self-conversation"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"}},"summary":"Get the user's MLS self-conversation"}},"/conversations/self":{"post":{"description":" [internal route ID: \"create-self-conversation\"]\n\n","operationId":"create-self-conversation","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_NDU4NDc3MDgzV6"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_NDU4NDc3MDgzV6"}}},"description":"Conversation existed","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_NDU4NDc3MDgzV6"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_NDU4NDc3MDgzV6"}}},"description":"Conversation created","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}}},"summary":"Create a self-conversation"}},"/conversations/{cnv_domain}/{cnv}":{"get":{"description":" [internal route ID: \"get-conversation\"]\n\n","operationId":"get-conversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Conversation_LTU5NTc0NTI2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get a conversation by ID"}},"/conversations/{cnv_domain}/{cnv}/access":{"put":{"description":" [internal route ID: \"update-conversation-access\"]\n\n","operationId":"update-conversation-access","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationAccessData_MjMxMTI5ODc3"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Access updated"},"204":{"description":"Access unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid target access"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid target access (label: `invalid-op`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nInsufficient authorization (missing modify_conversation_access) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update access modes for a conversation"}},"/conversations/{cnv_domain}/{cnv}/add-permission":{"put":{"description":" [internal route ID: \"update-channel-add-permission\"]\n\n","operationId":"update-channel-add-permission","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AddPermissionUpdate_LTU3MzEwOTY4"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Add permissions updated"},"204":{"description":"Add permissions unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid target access"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","not-connected","operation-denied","no-team-member","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid target access (label: `invalid-op`)\n\nUsers are not connected (label: `not-connected`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_add_permissions) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nTeam not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Update the permissions for adding members to a channel"}},"/conversations/{cnv_domain}/{cnv}/groupinfo":{"get":{"description":" [internal route ID: \"get-group-info\"]\n\n","operationId":"get-group-info","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/GroupInfoData"}}},"description":"The group information"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-missing-group-info","message":"The conversation has no group information"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-missing-group-info","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nThe conversation has no group information (label: `mls-missing-group-info`)\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get MLS group information"}},"/conversations/{cnv_domain}/{cnv}/history":{"put":{"description":" [internal route ID: \"update-conversation-history\"]\n\n","operationId":"update-conversation-history","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationHistoryUpdate_LTg5MDQ5Nzgx"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"History updated"},"204":{"description":"History unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"history-not-supported","message":"Shared history is not supported on this conversation"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["history-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nShared history is not supported on this conversation (label: `history-not-supported`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"action-denied","message":"Insufficient authorization (missing modify_conversation_access)"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["action-denied","invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient authorization (missing modify_conversation_access) (label: `action-denied`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update history settings of a conversation"}},"/conversations/{cnv_domain}/{cnv}/members":{"post":{"description":" [internal route ID: \"add-members-to-conversation\"]\n\n","operationId":"add-members-to-conversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InviteQualified_ODYyODIyNjYz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation updated"},"204":{"description":"Conversation unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-group-id-not-supported","message":"The group ID version of the conversation is not supported by one of the federated backends"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-group-id-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","not-connected","no-team-member","access-denied","too-many-members","invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nMaximum number of members per conversation reached (label: `too-many-members`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInsufficient authorization (missing add_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Add qualified members to an existing conversation."},"put":{"description":" [internal route ID: \"replace-members-in-conversation\"]\n\nThis will add any members not already in the conversation, and remove any members not in the provided list except users that are associated via a user group. The given role in the request body will be applied to all added members. The roles of already existing members will not be changed even if these members are included in the request body and their role differs from the role provided in this request.","operationId":"replace-members-in-conversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InviteQualified_ODYyODIyNjYz"}}},"required":true},"responses":{"200":{"description":"Conversation members replaced"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-group-id-not-supported","message":"The group ID version of the conversation is not supported by one of the federated backends"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-group-id-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","not-connected","no-team-member","access-denied","too-many-members","invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nMaximum number of members per conversation reached (label: `too-many-members`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nInsufficient authorization (missing add_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Replace the members of a conversation."}},"/conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}":{"delete":{"description":" [internal route ID: \"remove-member\"]\n\n","operationId":"remove-member","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"usr_domain","required":true,"schema":{"type":"string"}},{"description":"Target User ID","in":"path","name":"usr","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Member removed"},"204":{"description":"No change"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"eligible_members":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["eligible_members"],"type":"object"}}},"description":"The conversation would be left without an admin\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `usr_domain` or `usr` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Remove a member from a conversation"},"put":{"description":" [internal route ID: \"update-other-member\"]\n\n**Note**: at least one field has to be provided.","operationId":"update-other-member","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"usr_domain","required":true,"schema":{"type":"string"}},{"description":"Target User ID","in":"path","name":"usr","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OtherMemberUpdate_LTM1MjYzOTU0"}}},"required":true},"responses":{"200":{"description":"Membership updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nInvalid target (label: `invalid-op`)\n\nInsufficient authorization (missing modify_other_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation-member","message":"Conversation member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation-member","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `usr_domain` or `usr` not found\n\nConversation member not found (label: `no-conversation-member`)\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update membership of the specified user"}},"/conversations/{cnv_domain}/{cnv}/message-timer":{"put":{"description":" [internal route ID: \"update-conversation-message-timer\"]\n\n","operationId":"update-conversation-message-timer","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationMessageTimerUpdate_LTcxMjUwNzQ4"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Message timer updated"},"204":{"description":"Message timer unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_message_timer) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update the message timer for a conversation"}},"/conversations/{cnv_domain}/{cnv}/name":{"put":{"description":" [internal route ID: \"update-conversation-name\"]\n\n","operationId":"update-conversation-name","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationRename_ODkwODg1MzQ0"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Name unchanged"},"204":{"description":"Name updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_conversation_name) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update conversation name"}},"/conversations/{cnv_domain}/{cnv}/proteus/messages":{"post":{"description":" [internal route ID: \"post-proteus-message\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts `client_mismatch_strategy` in the body. It can have these values:\n- `report_all`: When set, the message is not sent if any clients are missing. The missing clients are reported in the response.\n- `ignore_all`: When set, no checks about missing clients are carried out.\n- `report_only`: Takes a list of qualified UserIDs. If any clients of the listed users are missing, the message is not sent. The missing clients are reported in the response.\n- `ignore_only`: Takes a list of qualified UserIDs. If any clients of the non-listed users are missing, the message is not sent. The missing clients are reported in the response.\n\nThe sending of messages in a federated conversation could theoretically fail partially. To make this case unlikely, the backend first gets a list of clients from all the involved backends and then tries to send a message. So, if any backend is down, the message is not propagated to anyone. But the actual message fan out to multiple backends could still fail partially. This type of failure is reported as a 201, the clients for which the message sending failed are part of the response body.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-proteus-message","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/QualifiedNewOtrMessage"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Message sent"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or Conversation not found (label: `no-conversation`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Missing clients"}},"summary":"Post an encrypted message to a conversation (accepts only Protobuf)"}},"/conversations/{cnv_domain}/{cnv}/protocol":{"put":{"description":" [internal route ID: \"update-conversation-protocol\"]\n\n**Note**: Only proteus->mixed upgrade is supported.","operationId":"update-conversation-protocol","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ProtocolUpdate_NzY1ODgxNDQy"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation updated"},"204":{"description":"Conversation unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-migration-criteria-not-satisfied","message":"The migration criteria for mixed to MLS protocol transition are not satisfied for this conversation"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-migration-criteria-not-satisfied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nThe migration criteria for mixed to MLS protocol transition are not satisfied for this conversation (label: `mls-migration-criteria-not-satisfied`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","invalid-op","action-denied","invalid-protocol-transition"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nProtocol transition is invalid (label: `invalid-protocol-transition`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nTeam not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update the protocol of the conversation"}},"/conversations/{cnv_domain}/{cnv}/receipt-mode":{"put":{"description":" [internal route ID: \"update-conversation-receipt-mode\"]\n\n","operationId":"update-conversation-receipt-mode","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationReceiptModeUpdate_NDE4MzUzNTU3"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Receipt mode updated"},"204":{"description":"Receipt mode unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-receipts-not-allowed","message":"Read receipts on MLS conversations are not allowed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-receipts-not-allowed","invalid-op","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Read receipts on MLS conversations are not allowed (label: `mls-receipts-not-allowed`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_receipt_mode) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update receipt mode for a conversation"}},"/conversations/{cnv_domain}/{cnv}/self":{"get":{"description":" [internal route ID: \"get-conversation-self\"]\n\n","operationId":"get-conversation-self","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Member_OTA5OTgyNzcw"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get self membership properties"},"put":{"description":" [internal route ID: \"update-conversation-self\"]\n\n**Note**: at least one field has to be provided.","operationId":"update-conversation-self","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MemberUpdate_LTg4NTQ0OTYz"}}},"required":true},"responses":{"200":{"description":"Update successful"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Update self membership properties"}},"/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}":{"delete":{"description":" [internal route ID: \"delete-subconversation\"]\n\n","operationId":"delete-subconversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSReset_NzgwODA3ODc4"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"Deletion successful"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-stale-message","message":"The conversation epoch in a message is too old"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-stale-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The conversation epoch in a message is too old (label: `mls-stale-message`)"}},"summary":"Delete an MLS subconversation"},"get":{"description":" [internal route ID: \"get-subconversation\"]\n\n","operationId":"get-subconversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicSubConversation_MjI2NTIxMzU4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PublicSubConversation_MjI2NTIxMzU4"}}},"description":"Subconversation"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-subconv-unsupported-convtype","message":"MLS subconversations are only supported for regular conversations"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-subconv-unsupported-convtype","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS subconversations are only supported for regular conversations (label: `mls-subconv-unsupported-convtype`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get information about an MLS subconversation"}},"/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}/groupinfo":{"get":{"description":" [internal route ID: \"get-subconversation-group-info\"]\n\n","operationId":"get-subconversation-group-info","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/GroupInfoData"}}},"description":"The group information"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-missing-group-info","message":"The conversation has no group information"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-missing-group-info","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nThe conversation has no group information (label: `mls-missing-group-info`)\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get MLS group information of subconversation"}},"/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}/self":{"delete":{"description":" [internal route ID: \"leave-subconversation\"]\n\n","operationId":"leave-subconversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled","mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nMLS protocol error (label: `mls-protocol-error`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-stale-message","message":"The conversation epoch in a message is too old"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-stale-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The conversation epoch in a message is too old (label: `mls-stale-message`)"}},"summary":"Leave an MLS subconversation"}},"/conversations/{cnv_domain}/{cnv}/typing":{"post":{"description":" [internal route ID: \"member-typing-qualified\"]\n\n","operationId":"member-typing-qualified","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TypingStatus_LTg5MzcyNDMy"}}},"required":true},"responses":{"200":{"description":"Notification sent"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Sending typing notifications"}},"/conversations/{cnv}/code":{"delete":{"description":" [internal route ID: \"remove-code-unqualified\"]\n\n","operationId":"remove-code-unqualified","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation code deleted."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Delete conversation code"},"get":{"description":" [internal route ID: \"get-code\"]\n\n","operationId":"get-code","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}}},"description":"Conversation Code"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"guest-links-disabled","message":"The guest link feature is disabled and all guest links have been revoked"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}},"summary":"Get existing conversation code"},"post":{"description":" [internal route ID: \"create-conversation-code-unqualified\"]\n\n\nOAuth scope: `write:conversations_code`","operationId":"create-conversation-code-unqualified","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateConversationCodeRequest_NTYzMTA1NDYz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}}},"description":"Conversation code already exists."},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation code created."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"create-conv-code-conflict","message":"Conversation code already exists with a different password setting than the requested one."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["create-conv-code-conflict","guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation code already exists with a different password setting than the requested one. (label: `create-conv-code-conflict`)\n\nThe guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}},"summary":"Create or recreate a conversation code"}},"/conversations/{cnv}/features/conversationGuestLinks":{"get":{"description":" [internal route ID: \"get-conversation-guest-links-status\"]\n\n","operationId":"get-conversation-guest-links-status","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get the status of the guest links feature for a conversation that potentially has been created by someone from another team."}},"/conversations/{cnv}/otr/messages":{"post":{"description":" [internal route ID: \"post-otr-message-unqualified\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts two query params:\n- `ignore_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are ignored.\n - When 'false' all missing clients are reported.\n - When comma separated list of user-ids, only clients for listed users are ignored.\n- `report_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are reported.\n - When 'false' all missing clients are ignored.\n - When comma separated list of user-ids, only clients for listed users are reported.\n\nApart from these, the request body also accepts `report_missing` which can only be a list of user ids and behaves the same way as the query parameter.\n\nAll three of these should be considered mutually exclusive. The server however does not error if more than one is specified, it reads them in this order of precedence:\n- `report_missing` in the request body has highest precedence.\n- `ignore_missing` in the query param is the next.\n- `report_missing` in the query param has the lowest precedence.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-otr-message-unqualified","parameters":[{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"ignore_missing","required":false,"schema":{"type":"string"}},{"in":"query","name":"report_missing","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}},"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Message sent"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` or Conversation not found (label: `no-conversation`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Missing clients"}},"summary":"Post an encrypted message to a conversation (accepts JSON or Protobuf)"}},"/conversations/{cnv}/roles":{"get":{"description":" [internal route ID: \"get-conversation-roles\"]\n\n","operationId":"get-conversation-roles","parameters":[{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationRolesList"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get existing roles available for the given conversation"}},"/cookies":{"get":{"description":" [internal route ID: \"list-cookies\"]\n\n","operationId":"list-cookies","parameters":[{"description":"Filter by label (comma-separated list)","in":"query","name":"labels","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CookieList_LTM4MzYwNzAz"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CookieList_LTM4MzYwNzAz"}}},"description":"List of cookies"}},"summary":"Retrieve the list of cookies currently stored for the user"}},"/cookies/remove":{"post":{"description":" [internal route ID: \"remove-cookies\"]\n\n","operationId":"remove-cookies","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RemoveCookies_OTYwMTI0NDMy"}}},"required":true},"responses":{"200":{"description":"Cookies revoked"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)"}},"summary":"Revoke stored cookies"}},"/custom-backend/by-domain/{domain}":{"get":{"description":" [internal route ID: \"get-custom-backend-by-domain\"]\n\n","operationId":"get-custom-backend-by-domain","parameters":[{"description":"URL-encoded email domain","in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CustomBackend_LTQxODI0MjQ0"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"custom-backend-not-found","message":"Custom backend not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["custom-backend-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` not found\n\nCustom backend not found (label: `custom-backend-not-found`)"}},"summary":"Shows information about custom backends related to a given email domain"}},"/delete":{"post":{"description":" [internal route ID: \"verify-delete\"]\n\n","operationId":"verify-delete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/VerifyDeleteUser_Njc1NDQ1MDIy"}}},"required":true},"responses":{"200":{"description":"Deletion is initiated."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-code","message":"Invalid verification code"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid verification code (label: `invalid-code`)"}},"summary":"Verify account deletion with a code."}},"/domain-verification/{domain}/authorize-team":{"post":{"description":" [internal route ID: \"domain-verification-authorize-team\"]\n\n","operationId":"domain-verification-authorize-team","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainOwnershipToken_NTU0ODc1NDE5"}}},"required":true},"responses":{"200":{"description":"Authorized"},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}},"summary":"Authorize a team to operate on a verified domain"}},"/domain-verification/{domain}/backend":{"post":{"description":" [internal route ID: \"update-domain-redirect\"]\n\n","operationId":"update-domain-redirect","parameters":[{"in":"header","name":"Authorization","required":true,"schema":{"type":"string"}},{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainRedirectConfig_NTI5NDE5MDQy"}}},"required":true},"responses":{"200":{"description":"Updated"},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}},"summary":"Update the domain redirect configuration"}},"/domain-verification/{domain}/challenges":{"post":{"description":" [internal route ID: \"domain-verification-challenge\"]\n\n","operationId":"domain-verification-challenge","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainVerificationChallenge_NjIwMzA1MjE5"}}},"description":""}},"summary":"Get a DNS verification challenge"}},"/domain-verification/{domain}/challenges/{challengeId}":{"post":{"description":" [internal route ID: \"verify-challenge\"]\n\n","operationId":"verify-challenge","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"challengeId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ChallengeToken_Mzk3NTcwOTM3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainOwnershipToken_NTU0ODc1NDE5"}}},"description":""},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"domain-verification-failed","message":"Domain verification failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["domain-verification-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain verification failed (label: `domain-verification-failed`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"challenge-not-found","message":"Challenge not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["challenge-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `challengeId` not found\n\nChallenge not found (label: `challenge-not-found`)"}},"summary":"Verify a DNS verification challenge"}},"/domain-verification/{domain}/team":{"post":{"description":" [internal route ID: \"update-team-invite\"]\n\n","operationId":"update-team-invite","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamInviteConfig_MTg4Nzk4NzMz"}}},"required":true},"responses":{"200":{"description":"Updated"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}},"summary":"Update the team-invite configuration"}},"/domain-verification/{domain}/team/challenges/{challengeId}":{"post":{"description":" [internal route ID: \"verify-challenge-team\"]\n\n","operationId":"verify-challenge-team","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"challengeId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ChallengeToken_Mzk3NTcwOTM3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainOwnershipToken_NTU0ODc1NDE5"}}},"description":""},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}},"summary":"Verify a DNS verification challenge for a team"}},"/events":{"get":{"description":" [internal route ID: \"consume-events\"]\n\nThis is the rabbitMQ-based variant of \"await-notifications\"","externalDocs":{"description":"RFC 6455","url":"https://datatracker.ietf.org/doc/html/rfc6455"},"operationId":"consume-events","parameters":[{"description":"Client ID","in":"query","name":"client","required":false,"schema":{"type":"string"}},{"description":"Synchronization marker ID","in":"query","name":"sync_marker","required":false,"schema":{"type":"string"}}],"responses":{"101":{"description":"Connection upgraded."},"426":{"description":"Upgrade required."}},"summary":"Consume events over a websocket connection"}},"/feature-configs":{"get":{"description":" [internal route ID: \"get-all-feature-configs-for-user\"]\n\nGets feature configs for a user. If the user is a member of a team and has the required permissions, this will return the team's feature configs.If the user is not a member of a team, this will return the personal feature configs (the server defaults).\nOAuth scope: `read:feature_configs`","operationId":"get-all-feature-configs-for-user","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NP_LockableFtur_ghdCnfiSOVsyAvIRqExmDpB_MGwUT2Q_LTM1Mzc0ODgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)"}},"summary":"Gets feature configs for a user"}},"/get-domain-registration":{"post":{"description":" [internal route ID: \"get-domain-registration\"]\n\n- `due_to_existing_account`: boolean (optional, only present if `domain_redirect` is `no-registration`)\n- `backend`: object (optional, must be present if `domain_redirect` is `backend`)\n - `config_url`: string (required)\n - `webapp_url`: string (optional)\n- `sso_code`: string (optional, must be present if `domain_redirect` is `sso`)","operationId":"get-domain-registration","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetDomainRegistrationRequest_LTg4NTM1MzM2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainRedirectResponse_V10_LTEyMjI4NTM0"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-domain","message":"Invalid domain"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-domain"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid domain (label: `invalid-domain`)"}},"summary":"Get domain registration configuration by email"}},"/handles":{"post":{"description":" [internal route ID: \"check-user-handles\"]\n\n","operationId":"check-user-handles","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CheckHandles_LTc0OTkxMzAx"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Handle"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Handle"},"type":"array"}}},"description":"List of free handles"}},"summary":"Check availability of user handles"}},"/handles/{handle}":{"head":{"description":" [internal route ID: \"check-user-handle\"]\n\n","operationId":"check-user-handle","parameters":[{"in":"path","name":"handle","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"Handle is taken"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-handle","message":"The given handle is invalid (less than 2 or more than 256 characters; chars not in \"a-z0-9_.-\"; or on the blocklist)"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-handle"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given handle is invalid (less than 2 or more than 256 characters; chars not in \"a-z0-9_.-\"; or on the blocklist) (label: `invalid-handle`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Handle not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`handle` not found\n\nHandle not found (label: `not-found`)"}},"summary":"Check whether a user handle can be taken"}},"/identity-providers":{"get":{"description":" [internal route ID: \"idp-get-all\"]\n\n","operationId":"idp-get-all","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPList"}}},"description":""}}},"post":{"description":" [internal route ID: \"idp-create\"]\n\nCreate a new identity provider.\n\nThe `api_version` parameter controls the uniqueness constraint for IdP issuers:\n- `v1`: IdP issuers must be globally unique across the entire backend (all teams)\n- `v2` (default): IdP issuers must be unique per team (can be reused across different teams)\n\nThese constraints apply to both, multi-ingress and standard backends.","operationId":"idp-create","parameters":[{"in":"query","name":"replaces","required":false,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"api_version","required":false,"schema":{"default":"v2","enum":["v1","v2"],"type":"string"}},{"in":"query","name":"handle","required":false,"schema":{"maxLength":32,"minLength":1,"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}},"application/xml":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}}},"required":true},"responses":{"201":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"}}},"description":""}}}},"/identity-providers/{id}":{"delete":{"description":" [internal route ID: \"idp-delete\"]\n\n","operationId":"idp-delete","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"purge","required":false,"schema":{"type":"boolean"}}],"responses":{"204":{"description":""}}},"get":{"description":" [internal route ID: \"idp-get\"]\n\n","operationId":"idp-get","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"}}},"description":""}}},"put":{"description":" [internal route ID: \"idp-update\"]\n\n","operationId":"idp-update","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"handle","required":false,"schema":{"maxLength":32,"minLength":1,"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}},"application/xml":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"}}},"description":""}}}},"/identity-providers/{id}/raw":{"get":{"description":" [internal route ID: \"idp-get-raw\"]\n\n","operationId":"idp-get-raw","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/xml":{"schema":{"type":"string"}}},"description":""}}}},"/list-connections":{"post":{"description":" [internal route ID: \"list-connections\"]\n\nThe IDs returned by this endpoint are paginated. To get the first page, make a call with the `paging_state` field set to `null` (or omitted). Whenever the `has_more` field of the response is set to `true`, more results are available, and they can be obtained by calling the endpoint again, but this time passing the value of `paging_state` returned by the previous call. One can continue in this fashion until all results are returned, which is indicated by `has_more` being `false`. Note that `paging_state` should be considered an opaque token. It should not be inspected, or stored, or reused across multiple unrelated invocations of the endpoint.","operationId":"list-connections","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetMuliTabPgRqs_ConcLOrm501_LTIwMjA4NzYw"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MultiTabePg_ConcsLOrRmU_MTgwMzU4OTU5"}}},"description":""}},"summary":"List the connections to other users, including remote users"}},"/list-users":{"post":{"description":" [internal route ID: \"list-users-by-ids-or-handles\"]\n\nThe 'qualified_ids' and 'qualified_handles' parameters are mutually exclusive.","operationId":"list-users-by-ids-or-handles","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ListUsersQuery"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ListUsersById_LTQ5MTE3NDc0"}}},"description":""}},"summary":"List users"}},"/login":{"post":{"description":" [internal route ID: \"login\"]\n\nLogins are throttled at the server's discretion","operationId":"login","parameters":[{"description":"Request a persistent cookie instead of a session cookie","in":"query","name":"persist","required":false,"schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Login_LTgyNTIzMTM1"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed","pending-activation","suspended","invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nAccount pending activation (label: `pending-activation`)\n\nAccount suspended (label: `suspended`)\n\nAuthentication failed (label: `invalid-credentials`)"}},"summary":"Authenticate a user to obtain a cookie and first access token"}},"/meetings":{"post":{"description":" [internal route ID: \"create-meeting\"]\n\n","operationId":"create-meeting","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewMeeting_LTI1NTMzOTU5"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"}}},"description":"Meeting created"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Create a new meeting"}},"/meetings/list":{"get":{"description":" [internal route ID: \"list-meetings\"]\n\n","operationId":"list-meetings","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"},"type":"array"}}},"description":""}},"summary":"List all meetings for the authenticated user"}},"/meetings/{domain}/{id}":{"delete":{"description":" [internal route ID: \"delete-meeting\"]\n\n","operationId":"delete-meeting","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"Meeting deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"You do not have permission to access this resource"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"You do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}},"summary":"Delete a meeting"},"get":{"description":" [internal route ID: \"get-meeting\"]\n\n","operationId":"get-meeting","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}},"summary":"Get a single meeting by ID"},"put":{"description":" [internal route ID: \"update-meeting\"]\n\n","operationId":"update-meeting","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateMeeting_NTExNzYxMTcz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"}}},"description":"Meeting updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}},"summary":"Update an existing meeting"}},"/meetings/{domain}/{id}/invitations":{"post":{"description":" [internal route ID: \"add-meeting-invitation\"]\n\n","operationId":"add-meeting-invitation","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MeetingEmailsInvitation_NzgyNzUzMzcz"}}},"required":true},"responses":{"200":{"description":"Invitation added"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"You do not have permission to access this resource"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"You do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}},"summary":"Add an email to the invited emails"}},"/meetings/{domain}/{id}/invitations/delete":{"post":{"description":" [internal route ID: \"remove-meeting-invitation\"]\n\n","operationId":"remove-meeting-invitation","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MeetingEmailsInvitation_NzgyNzUzMzcz"}}},"required":true},"responses":{"200":{"description":"Invitations removed"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"You do not have permission to access this resource"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"You do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}},"summary":"Remove emails from the invited emails"}},"/mls/commit-bundles":{"post":{"description":" [internal route ID: \"mls-commit-bundle\"]\n\n\n\n**Note**: this endpoint can execute proposals, and therefore return all possible errors associated with adding or removing members to a conversation, in addition to the ones listed below. See the documentation of [POST /conversations/{cnv}/members/v2](#/default/post_conversations__cnv__members_v2) and [POST /conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}](#/default/delete_conversations__cnv_domain___cnv__members__usr_domain___usr_) for more details on the possible error responses of each type of proposal.","operationId":"mls-commit-bundle","requestBody":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/CommitBundle"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}}},"description":"Commit accepted and forwarded"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-invalid-leaf-node-signature","message":"Invalid leaf node signature"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-invalid-leaf-node-signature","mls-group-id-not-supported","mls-welcome-mismatch","mls-self-removal-not-allowed","mls-protocol-error","mls-not-enabled","mls-invalid-leaf-node-index","mls-group-conversation-mismatch","mls-commit-missing-references","mls-client-sender-user-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid leaf node signature (label: `mls-invalid-leaf-node-signature`)\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)\n\nSubmitted group info is inconsistent with the backend group state (label `inconsistent-group-state`)\n\nThe list of targets of a welcome message does not match the list of new clients in a group (label: `mls-welcome-mismatch`)\n\nSelf removal from group is not allowed (label: `mls-self-removal-not-allowed`)\n\nMLS protocol error (label: `mls-protocol-error`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nA referenced leaf node index points to a blank or non-existing node (label: `mls-invalid-leaf-node-index`)\n\nConversation ID resolved from Group ID does not match submitted Conversation ID (label: `mls-group-conversation-mismatch`)\n\nThe commit is not referencing all pending proposals (label: `mls-commit-missing-references`)\n\nUser ID resolved from Client ID does not match message's sender user ID (label: `mls-client-sender-user-mismatch`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-identity-mismatch","message":"Leaf node signature key does not match the client's key"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-identity-mismatch","mls-subconv-join-parent-missing","missing-legalhold-consent","legalhold-not-enabled","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Leaf node signature key does not match the client's key (label: `mls-identity-mismatch`)\n\nMLS client cannot join the subconversation because it is not member of the parent conversation (label: `mls-subconv-join-parent-missing`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-proposal-not-found","message":"A proposal referenced in a commit message could not be found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-proposal-not-found","no-conversation","no-conversation-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"A proposal referenced in a commit message could not be found (label: `mls-proposal-not-found`)\n\nConversation not found (label: `no-conversation`)\n\nConversation member not found (label: `no-conversation-member`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"missing_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["missing_users"],"type":"object"}}},"description":"Group is out of sync\n\nAdding members to the conversation is not possible because the backends involved do not form a fully connected graph\n\nA user who is under legal-hold may not participate in MLS conversations (label: `mls-legal-hold-not-allowed`)\n\nThe conversation epoch in a message is too old (label: `mls-stale-message`)\n\nA proposal of type Add or Remove does not apply to the full list of clients for a user (label: `mls-client-mismatch`)"},"422":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":422,"label":"mls-unsupported-proposal","message":"Unsupported proposal type"},"properties":{"code":{"enum":[422],"type":"integer"},"label":{"enum":["mls-unsupported-proposal","mls-unsupported-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unsupported proposal type (label: `mls-unsupported-proposal`)\n\nAttempted to send a message with an unsupported combination of content type and wire format (label: `mls-unsupported-message`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Post a MLS CommitBundle"}},"/mls/key-packages/claim/{user_domain}/{user}":{"post":{"description":" [internal route ID: \"mls-key-packages-claim\"]\n\nOnly key packages for the specified ciphersuite are claimed.","operationId":"mls-key-packages-claim","parameters":[{"in":"path","name":"user_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"user","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Ciphersuite in hex format (e.g. 0x0002)","in":"query","name":"ciphersuite","required":true,"schema":{"type":"number"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyPackageBundle_MjU2MjY0MDU2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageBundle_MjU2MjY0MDU2"}}},"description":"Claimed key packages"}},"summary":"Claim one key package for each client of the given user"}},"/mls/key-packages/self/{client}":{"delete":{"description":" [internal route ID: \"mls-key-packages-delete\"]\n\n","operationId":"mls-key-packages-delete","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Ciphersuite in hex format (e.g. 0x0002)","in":"query","name":"ciphersuite","required":true,"schema":{"type":"number"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteKeyPackages_LTQxNTcxNjY3"}}},"required":true},"responses":{"201":{"description":"OK"}},"summary":"Delete all key packages for a given ciphersuite and client"},"post":{"description":" [internal route ID: \"mls-key-packages-upload\"]\n\nThe request body should be a json object containing a list of base64-encoded key packages.","operationId":"mls-key-packages-upload","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageUpload_NTQ2Mjk2NzEx"}}},"required":true},"responses":{"201":{"description":"Key packages uploaded"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-protocol-error","message":"MLS protocol error"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMLS protocol error (label: `mls-protocol-error`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-identity-mismatch","message":"Key package credential does not match qualified client ID"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-identity-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Key package credential does not match qualified client ID (label: `mls-identity-mismatch`)"}},"summary":"Upload a fresh batch of key packages"},"put":{"description":" [internal route ID: \"mls-key-packages-replace\"]\n\nThe request body should be a json object containing a list of base64-encoded key packages. Use this sparingly.","operationId":"mls-key-packages-replace","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Comma-separated list of ciphersuites in hex format (e.g. 0x0002)","in":"query","name":"ciphersuites","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageUpload_NTQ2Mjk2NzEx"}}},"required":true},"responses":{"201":{"description":"Key packages replaced"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-protocol-error","message":"MLS protocol error"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `ciphersuites`\n\nMLS protocol error (label: `mls-protocol-error`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-identity-mismatch","message":"Key package credential does not match qualified client ID"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-identity-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Key package credential does not match qualified client ID (label: `mls-identity-mismatch`)"}},"summary":"Upload a fresh batch of key packages and replace the old ones"}},"/mls/key-packages/self/{client}/count":{"get":{"description":" [internal route ID: \"mls-key-packages-count\"]\n\n","operationId":"mls-key-packages-count","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Ciphersuite in hex format (e.g. 0x0002)","in":"query","name":"ciphersuite","required":true,"schema":{"type":"number"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyPackageCount_LTYwNDg5MDcz"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageCount_LTYwNDg5MDcz"}}},"description":"Number of key packages"}},"summary":"Return the number of unclaimed key packages for a given ciphersuite and client"}},"/mls/messages":{"post":{"description":" [internal route ID: \"mls-message\"]\n\n\n\n**Note**: this endpoint can execute proposals, and therefore return all possible errors associated with adding or removing members to a conversation, in addition to the ones listed below. See the documentation of [POST /conversations/{cnv}/members/v2](#/default/post_conversations__cnv__members_v2) and [POST /conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}](#/default/delete_conversations__cnv_domain___cnv__members__usr_domain___usr_) for more details on the possible error responses of each type of proposal.","operationId":"mls-message","requestBody":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/MLSMessage"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}}},"description":"Message sent"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-invalid-leaf-node-signature","message":"Invalid leaf node signature"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-invalid-leaf-node-signature","mls-self-removal-not-allowed","mls-protocol-error","mls-not-enabled","mls-invalid-leaf-node-index","mls-group-conversation-mismatch","mls-commit-missing-references","mls-client-sender-user-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid leaf node signature (label: `mls-invalid-leaf-node-signature`)\n\nSubmitted group info is inconsistent with the backend group state (label `inconsistent-group-state`)\n\nSelf removal from group is not allowed (label: `mls-self-removal-not-allowed`)\n\nMLS protocol error (label: `mls-protocol-error`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nA referenced leaf node index points to a blank or non-existing node (label: `mls-invalid-leaf-node-index`)\n\nConversation ID resolved from Group ID does not match submitted Conversation ID (label: `mls-group-conversation-mismatch`)\n\nThe commit is not referencing all pending proposals (label: `mls-commit-missing-references`)\n\nUser ID resolved from Client ID does not match message's sender user ID (label: `mls-client-sender-user-mismatch`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-subconv-join-parent-missing","message":"MLS client cannot join the subconversation because it is not member of the parent conversation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-subconv-join-parent-missing","missing-legalhold-consent","legalhold-not-enabled","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS client cannot join the subconversation because it is not member of the parent conversation (label: `mls-subconv-join-parent-missing`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-proposal-not-found","message":"A proposal referenced in a commit message could not be found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-proposal-not-found","no-conversation","no-conversation-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"A proposal referenced in a commit message could not be found (label: `mls-proposal-not-found`)\n\nConversation not found (label: `no-conversation`)\n\nConversation member not found (label: `no-conversation-member`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"missing_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["missing_users"],"type":"object"}}},"description":"Group is out of sync\n\nAdding members to the conversation is not possible because the backends involved do not form a fully connected graph\n\nThe conversation epoch in a message is too old (label: `mls-stale-message`)\n\nA proposal of type Add or Remove does not apply to the full list of clients for a user (label: `mls-client-mismatch`)"},"422":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":422,"label":"mls-unsupported-proposal","message":"Unsupported proposal type"},"properties":{"code":{"enum":[422],"type":"integer"},"label":{"enum":["mls-unsupported-proposal","mls-unsupported-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unsupported proposal type (label: `mls-unsupported-proposal`)\n\nAttempted to send a message with an unsupported combination of content type and wire format (label: `mls-unsupported-message`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Post an MLS message"}},"/mls/public-keys":{"get":{"description":" [internal route ID: \"mls-public-keys\"]\n\nThe format of the returned key is determined by the `format` query parameter:\n - raw (default): base64-encoded raw public keys\n - jwk: keys are nested objects in JWK format.","operationId":"mls-public-keys","parameters":[{"in":"query","name":"format","required":false,"schema":{"enum":["raw","jwk"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx"}}},"description":"Public keys"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `format`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"}},"summary":"Get public keys used by the backend to sign external proposals"}},"/mls/reset-conversation":{"post":{"description":" [internal route ID: \"mls-reset-conversation\"]\n\n","operationId":"mls-reset-conversation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSReset_NzgwODA3ODc4"}}},"required":true},"responses":{"200":{"description":"Conversation reset"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-protocol-error","message":"MLS protocol error"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-protocol-error","mls-group-id-not-supported","mls-federated-reset-not-supported","mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS protocol error (label: `mls-protocol-error`)\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)\n\nReset is not supported by the owning backend of the conversation (label: `mls-federated-reset-not-supported`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`) or `body`"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"action-denied","message":"Insufficient authorization (missing leave_conversation)"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["action-denied","invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-stale-message","message":"The conversation epoch in a message is too old"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-stale-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The conversation epoch in a message is too old (label: `mls-stale-message`)"}},"summary":"Reset an MLS conversation to epoch 0"}},"/notifications":{"get":{"description":" [internal route ID: \"get-notifications\"]\n\nSee also: GET /teams/notifications","operationId":"get-notifications","parameters":[{"description":"Only return notifications more recent than this","in":"query","name":"since","required":false,"schema":{"format":"uuid","type":"string"}},{"description":"Only return notifications targeted at the given client","in":"query","name":"client","required":false,"schema":{"type":"string"}},{"description":"Maximum number of notifications to return","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":10000,"minimum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueuedNotificationList_MTU0ODEyNTQ2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotificationList_MTU0ODEyNTQ2"}}},"description":"Notification list"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Some notifications not found (label: `not-found`)"}},"summary":"Fetch notifications"}},"/notifications/last":{"get":{"description":" [internal route ID: \"get-last-notification\"]\n\n","operationId":"get-last-notification","parameters":[{"description":"Only return notifications targeted at the given client","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}}},"description":"Notification found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Some notifications not found (label: `not-found`)"}},"summary":"Fetch the last notification"}},"/notifications/{id}":{"get":{"description":" [internal route ID: \"get-notification-by-id\"]\n\n","operationId":"get-notification-by-id","parameters":[{"description":"Notification ID","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Only return notifications targeted at the given client","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}}},"description":"Notification found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`id` or Some notifications not found (label: `not-found`)"}},"summary":"Fetch a notification by ID"}},"/oauth/applications":{"get":{"description":" [internal route ID: \"get-oauth-applications\"]\n\nGet all OAuth applications with active account access for a user.","operationId":"get-oauth-applications","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/OAuthApplication_Mjk5NTUxNjA1"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/OAuthApplication_Mjk5NTUxNjA1"},"type":"array"}}},"description":"OAuth applications found"}},"summary":"Get OAuth applications with account access"}},"/oauth/applications/{OAuthClientId}/sessions":{"delete":{"description":" [internal route ID: \"revoke-oauth-account-access\"]\n\n","operationId":"revoke-oauth-account-access","parameters":[{"description":"The ID of the OAuth client","in":"path","name":"OAuthClientId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordReqBody_LTcxMzE3ODE3"}}},"required":true},"responses":{"204":{"description":"OAuth application access revoked"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"Revoke account access from an OAuth application"}},"/oauth/applications/{OAuthClientId}/sessions/{RefreshTokenId}":{"delete":{"description":" [internal route ID: \"delete-oauth-refresh-token\"]\n\nRevoke an active OAuth session by providing the refresh token ID.","operationId":"delete-oauth-refresh-token","parameters":[{"description":"The ID of the OAuth client","in":"path","name":"OAuthClientId","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"The ID of the refresh token","in":"path","name":"RefreshTokenId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordReqBody_LTcxMzE3ODE3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`OAuthClientId` or `RefreshTokenId` not found\n\nOAuth client not found (label: `not-found`)"}},"summary":"Revoke an active OAuth session"}},"/oauth/authorization/codes":{"post":{"description":" [internal route ID: \"create-oauth-auth-code\"]\n\nCurrently only supports the 'code' response type, which corresponds to the authorization code flow.","operationId":"create-oauth-auth-code","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateOAuthAuthorizationCodeRequest_LTQ3NzI1Mjkz"}}},"required":true},"responses":{"201":{"description":"Created","headers":{"Location":{"schema":{"type":"string"}}}},"400":{"content":{"application/json":{"schema":{"example":{"code":400,"label":"redirect-url-miss-match","message":"The redirect URL does not match the one registered with the client"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["redirect-url-miss-match"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"redirect-url-miss-match","message":"The redirect URL does not match the one registered with the client"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["redirect-url-miss-match"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Bad Request\n\nThe redirect URL does not match the one registered with the client (label: `redirect-url-miss-match`) or `body`","headers":{"Location":{"schema":{"type":"string"}}}},"403":{"description":"Forbidden","headers":{"Location":{"schema":{"type":"string"}}}},"404":{"description":"Not Found","headers":{"Location":{"schema":{"type":"string"}}}}},"summary":"Create an OAuth authorization code"}},"/oauth/clients/{OAuthClientId}":{"get":{"description":" [internal route ID: \"get-oauth-client\"]\n\n","operationId":"get-oauth-client","parameters":[{"description":"The ID of the OAuth client","in":"path","name":"OAuthClientId","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthClient_NzExMTI5NTIy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OAuthClient_NzExMTI5NTIy"}}},"description":"OAuth client found"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"forbidden","message":"OAuth is disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"OAuth is disabled (label: `forbidden`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`OAuthClientId` or OAuth client not found (label: `not-found`)\n\nOAuth client not found (label: `not-found`)"}},"summary":"Get OAuth client information"}},"/oauth/revoke":{"post":{"description":" [internal route ID: \"revoke-oauth-refresh-token\"]\n\nRevoke an access token.","operationId":"revoke-oauth-refresh-token","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OAuthRevokeRefreshTokenRequest_MjA1MDg4MzQ4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"forbidden","message":"Invalid refresh token"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid refresh token (label: `forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"OAuth client not found (label: `not-found`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"jwt-error","message":"Internal error while handling JWT token"},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["jwt-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Internal error while handling JWT token (label: `jwt-error`)"}},"summary":"Revoke an OAuth refresh token"}},"/oauth/token":{"post":{"description":" [internal route ID: \"create-oauth-access-token\"]\n\nObtain a new access token from an authorization code or a refresh token.","operationId":"create-oauth-access-token","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Either_OAuthAccessTokenRequest_OAuthRefreshAccessTokenRequest"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OAuthAccessTokenResponse_NzEwOTI4NjQ0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid_grant","message":"Invalid grant"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid_grant","forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid grant (label: `invalid_grant`)\n\nInvalid client credentials (label: `forbidden`)\n\nInvalid grant type (label: `forbidden`)\n\nInvalid refresh token (label: `forbidden`)\n\nOAuth is disabled (label: `forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"OAuth client not found (label: `not-found`)\n\nOAuth authorization code not found (label: `not-found`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"jwt-error","message":"Internal error while handling JWT token"},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["jwt-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Internal error while handling JWT token (label: `jwt-error`)"}},"summary":"Create an OAuth access token"}},"/one2one-conversations":{"post":{"description":" [internal route ID: \"create-one-to-one-conversation\"]\n\n","operationId":"create-one-to-one-conversation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOne2OneConv_LTI3OTc4NDAz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_NDU4NDc3MDgzV3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_NDU4NDc3MDgzV3"}}},"description":"Conversation existed","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_NDU4NDc3MDgzV3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_NDU4NDc3MDgzV3"}}},"description":"Conversation created","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","operation-denied","not-connected","no-team-member","non-binding-team-members","invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nInsufficient permissions (label: `operation-denied`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nBoth users must be members of the same binding team (label: `non-binding-team-members`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","non-binding-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)\n\nNot a member of a binding team (label: `non-binding-team`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}},"summary":"Create a 1:1 conversation"}},"/one2one-conversations/{usr_domain}/{usr}":{"get":{"description":" [internal route ID: \"get-one-to-one-mls-conversation\"]\n\n","operationId":"get-one-to-one-mls-conversation","parameters":[{"in":"path","name":"usr_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"usr","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"format","required":false,"schema":{"enum":["raw","jwk"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSOne2OneConversation_SomeKey_LTMxNjE2NjUy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSOne2OneConversation_SomeKey_LTMxNjE2NjUy"}}},"description":"MLS 1-1 conversation"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `format`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"not-connected","message":"Users are not connected"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["not-connected"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Users are not connected (label: `not-connected`)"}},"summary":"Get an MLS 1:1 conversation"}},"/password-reset":{"post":{"description":" [internal route ID: \"post-password-reset\"]\n\n","operationId":"post-password-reset","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewPasswordReset_LTEyNzAxMTcy"}}},"required":true},"responses":{"201":{"description":"Password reset code created and sent by email."}},"summary":"Initiate a password reset."}},"/password-reset/complete":{"post":{"description":" [internal route ID: \"post-password-reset-complete\"]\n\n","operationId":"post-password-reset-complete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CompletePasswordReset_NDcyMjY5OTc4"}}},"required":true},"responses":{"200":{"description":"Password reset successful."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-code","message":"Invalid password reset code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)"}},"summary":"Complete a password reset."}},"/properties":{"delete":{"description":" [internal route ID: \"clear-properties\"]\n\n","operationId":"clear-properties","responses":{"200":{"description":"Properties cleared"}},"summary":"Clear all properties"},"get":{"description":" [internal route ID: \"list-property-keys\"]\n\n","operationId":"list-property-keys","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ASCII"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/ASCII"},"type":"array"}}},"description":"List of property keys"}},"summary":"List all property keys"}},"/properties-values":{"get":{"description":" [internal route ID: \"list-properties\"]\n\n","operationId":"list-properties","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PropertyKeysAndValues"}}},"description":""}},"summary":"List all properties with key and value"}},"/properties/{key}":{"delete":{"description":" [internal route ID: \"delete-property\"]\n\n","operationId":"delete-property","parameters":[{"in":"path","name":"key","required":true,"schema":{"format":"printable","type":"string"}}],"responses":{"200":{"description":"Property deleted"}},"summary":"Delete a property"},"get":{"description":" [internal route ID: \"get-property\"]\n\n","operationId":"get-property","parameters":[{"in":"path","name":"key","required":true,"schema":{"format":"printable","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertyValue"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PropertyValue"}}},"description":"The property value"},"404":{"description":"`key` or Property not found(**Note**: This error has an empty body for legacy reasons)"}},"summary":"Get a property value"},"put":{"description":" [internal route ID: \"set-property\"]\n\n","operationId":"set-property","parameters":[{"in":"path","name":"key","required":true,"schema":{"format":"printable","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PropertyValue"}}},"required":true},"responses":{"200":{"description":"Property set"}},"summary":"Set a user property"}},"/provider":{"delete":{"description":" [internal route ID: \"provider-delete\"]\n\n","operationId":"provider-delete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteProvider_MzYxMzM3Mjg2"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","invalid-provider","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nThe provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Delete a provider"},"get":{"description":" [internal route ID: \"provider-get-account\"]\n\n","operationId":"provider-get-account","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Provider not found. (label: `not-found`)\n\nProvider not found. (label: `not-found`)"}},"summary":"Get account"},"put":{"description":" [internal route ID: \"provider-update\"]\n\n","operationId":"provider-update","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateProvider_LTQwMjY4MDgy"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-provider","message":"The provider does not exist."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-provider","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Update a provider"}},"/provider/activate":{"get":{"description":" [internal route ID: \"provider-activate\"]\n\n","operationId":"provider-activate","parameters":[{"in":"query","name":"key","required":true,"schema":{"type":"string"}},{"in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderActivationResponse_LTgzNTU3MzA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ProviderActivationResponse_LTgzNTU3MzA5"}}},"description":""},"204":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-code","message":"Invalid verification code"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-code","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid verification code (label: `invalid-code`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Activate a provider"}},"/provider/assets":{"post":{"description":" [internal route ID: (\"assets-upload-v3\", provider)]\n\n

Construct the request as multipart/mixed; set header Content-Type: multipart/mixed; boundary=<boundary>.

Use exactly two parts in this order:

  1. application/json metadata (AssetSettings)
  2. application/octet-stream asset bytes

Each part must include Content-Type and Content-Length; the second part may include Content-MD5. Use CRLF between headers and bodies.

When asset audit logging is enabled, the JSON metadata must include:

  • convId: object { id: UUID, domain: String } (qualified conversation ID)
  • filename: String
  • filetype: String MIME type (e.g. image/png, application/pdf)

Optional metadata: public (Bool, default false), retention (one of eternal, persistent, volatile, eternal-infrequent_access, expiring).

For profile pictures or team icons without a conversation, set convId.id to 00000000-0000-0000-0000-000000000000 and convId.domain to the tenant’s domain; use any reasonable filename.

Note: the server treats the asset bytes as application/octet-stream; filetype is used for auditing only.

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

--frontier
Content-Type: application/json
Content-Length: 191

{\"public\":false,\"retention\":\"volatile\",\"convId\":{\"id\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"domain\":\"example.com\"},\"filename\":\"report.pdf\",\"filetype\":\"application/pdf\"}
--frontier
Content-Type: application/octet-stream
Content-Length: 11

Hello Audit
--frontier--
","operationId":"assets-upload-v3_provider","requestBody":{"content":{"multipart/mixed":{"schema":{"$ref":"#/components/schemas/AssetSource"}}},"description":"A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server."},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}}},"description":"Asset posted","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"incomplete-body","message":"HTTP content-length header does not match body size"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["incomplete-body","invalid-length"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nHTTP content-length header does not match body size (label: `incomplete-body`)\n\nInvalid content length (label: `invalid-length`)"},"413":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"client-error","message":"Asset too large"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["client-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Asset too large (label: `client-error`)"}},"summary":"Upload an asset"}},"/provider/assets/{key}":{"delete":{"description":" [internal route ID: (\"assets-delete-v3\", provider)]\n\n","operationId":"assets-delete-v3_provider","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` not found\n\nAsset not found (label: `not-found`)"}},"summary":"Delete an asset"},"get":{"description":" [internal route ID: (\"assets-download-v3\", provider)]\n\n","operationId":"assets-download-v3_provider","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}},{"in":"header","name":"Asset-Token","required":false,"schema":{"type":"string"}},{"in":"query","name":"asset_token","required":false,"schema":{"type":"string"}}],"responses":{"302":{"description":"Asset found","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` or Asset not found (label: `not-found`)"}},"summary":"Download an asset"}},"/provider/email":{"put":{"description":" [internal route ID: \"provider-update-email\"]\n\n","operationId":"provider-update-email","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/EmailUpdate_LTYwODE0ODQ5"}}},"required":true},"responses":{"202":{"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-provider","message":"The provider does not exist."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-provider","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Too many request to generate a verification code."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many request to generate a verification code. (label: `too-many-requests`)"}},"summary":"Update a provider email"}},"/provider/login":{"post":{"description":" [internal route ID: \"provider-login\"]\n\n","operationId":"provider-login","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ProviderLogin_LTE2MTk2NTM5"}}},"required":true},"responses":{"200":{"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"}},"summary":"Login as a provider"}},"/provider/password":{"put":{"description":" [internal route ID: \"provider-update-password\"]\n\n","operationId":"provider-update-password","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordChange_NDI0ODgwNDU0"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password reset, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password reset, new and old password must be different. (label: `password-must-differ`)"}},"summary":"Update a provider password"}},"/provider/password-reset":{"post":{"description":" [internal route ID: \"provider-password-reset\"]\n\n","operationId":"provider-password-reset","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordReset_LTYzNDYxNTQ3"}}},"required":true},"responses":{"201":{"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-code","message":"Invalid password reset code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-code","invalid-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)\n\nInvalid email or mobile number for password reset. (label: `invalid-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password reset, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ","code-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password reset, new and old password must be different. (label: `password-must-differ`)\n\nA password reset is already in progress. (label: `code-exists`)\n\nA password reset is already in progress. (label: `code-exists`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Too many request to generate a verification code."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many request to generate a verification code. (label: `too-many-requests`)"}},"summary":"Begin a password reset"}},"/provider/password-reset/complete":{"post":{"description":" [internal route ID: \"provider-password-reset-complete\"]\n\n","operationId":"provider-password-reset-complete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CompletePasswordReset_LTYzMDAxNDA1"}}},"required":true},"responses":{"200":{"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-code","message":"Invalid password reset code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","invalid-code","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nInvalid verification code (label: `invalid-code`)\n\nAccess denied. (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password reset, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password reset, new and old password must be different. (label: `password-must-differ`)"}},"summary":"Complete a password reset"}},"/provider/register":{"post":{"description":" [internal route ID: \"provider-register\"]\n\n","operationId":"provider-register","parameters":[{"in":"header","name":"X-Forwarded-For","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewProvider_LTEyMTY5MjYy"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewProviderResponse_OTE0ODI2NjU0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewProviderResponse_OTE0ODI2NjU0"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `X-Forwarded-For`\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Too many request to generate a verification code."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many request to generate a verification code. (label: `too-many-requests`)"}},"summary":"Register a new provider"}},"/provider/services":{"get":{"description":" [internal route ID: \"get-provider-services\"]\n\n","operationId":"get-provider-services","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Service_MjcyOTA5NjQx"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"List provider services"},"post":{"description":" [internal route ID: \"post-provider-services\"]\n\n","operationId":"post-provider-services","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewService_LTYwOTU1MDQ3"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewServiceResponse_LTExMzcwMjg5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewServiceResponse_LTExMzcwMjg5"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-service-key","message":"Invalid service key."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-service-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid service key. (label: `invalid-service-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"Create a new service"}},"/provider/services/{service-id}":{"delete":{"description":" [internal route ID: \"delete-provider-services-by-service-id\"]\n\n","operationId":"delete-provider-services-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteService_LTY2NzY5NzMz"}}},"required":true},"responses":{"202":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nService not found. (label: `not-found`)"}},"summary":"Delete service"},"get":{"description":" [internal route ID: \"get-provider-services-by-service-id\"]\n\n","operationId":"get-provider-services-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Service_MjcyOTA5NjQx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nService not found. (label: `not-found`)"}},"summary":"Get provider service by service id"},"put":{"description":" [internal route ID: \"put-provider-services-by-service-id\"]\n\n","operationId":"put-provider-services-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateService_MjAxNzQ2Njkz"}}},"required":true},"responses":{"200":{"description":"Provider service updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nProvider not found. (label: `not-found`)\n\nService not found. (label: `not-found`)"}},"summary":"Update provider service"}},"/provider/services/{service-id}/connection":{"put":{"description":" [internal route ID: \"put-provider-services-connection-by-service-id\"]\n\n","operationId":"put-provider-services-connection-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateServiceConn_LTQ1OTYwNjIz"}}},"required":true},"responses":{"200":{"description":"Provider service connection updated"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-service-key","message":"Invalid service key."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-service-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid service key. (label: `invalid-service-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nService not found. (label: `not-found`)"}},"summary":"Update provider service connection"}},"/providers/{pid}":{"get":{"description":" [internal route ID: \"provider-get-profile\"]\n\n","operationId":"provider-get-profile","parameters":[{"in":"path","name":"pid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}}},"description":""},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`pid` or Provider not found. (label: `not-found`)"}},"summary":"Get profile"}},"/providers/{provider-id}/services":{"get":{"description":" [internal route ID: \"get-provider-services-by-provider-id\"]\n\n","operationId":"get-provider-services-by-provider-id","parameters":[{"in":"path","name":"provider-id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/ServiceProfile_LTc2MDQzNTk3"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"Get provider services by provider id"}},"/providers/{provider-id}/services/{service-id}":{"get":{"description":" [internal route ID: \"get-provider-services-by-provider-id-and-service-id\"]\n\n","operationId":"get-provider-services-by-provider-id-and-service-id","parameters":[{"in":"path","name":"provider-id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceProfile_LTc2MDQzNTk3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`provider-id` or `service-id` not found\n\nService not found. (label: `not-found`)"}},"summary":"Get provider service by provider id and service id"}},"/proxy/giphy/v1/gifs":{},"/proxy/googlemaps/api/staticmap":{},"/proxy/googlemaps/maps/api/geocode":{},"/proxy/soundcloud/resolve":{},"/proxy/soundcloud/stream":{},"/proxy/spotify/api/token":{},"/proxy/youtube/v3":{},"/push/tokens":{"get":{"description":" [internal route ID: \"get-push-tokens\"]\n\n","operationId":"get-push-tokens","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PushTokenList_NDI0Mjc3MzY3"}}},"description":""}},"summary":"List the user's registered push tokens"},"post":{"description":" [internal route ID: \"register-push-token\"]\n\n","operationId":"register-push-token","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"}}},"description":"Push token registered","headers":{"Location":{"schema":{"type":"string"}}}},"400":{"content":{"application/json":{"schema":{"example":{"code":400,"label":"apns-voip-not-supported","message":"Adding APNS_VOIP tokens is not supported"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["apns-voip-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"apns-voip-not-supported","message":"Adding APNS_VOIP tokens is not supported"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["apns-voip-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Adding APNS_VOIP tokens is not supported (label: `apns-voip-not-supported`) or `body`"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"app-not-found","message":"App does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["app-not-found","invalid-token"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"app-not-found","message":"App does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["app-not-found","invalid-token"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"App does not exist (label: `app-not-found`)\n\nInvalid push token (label: `invalid-token`)"},"413":{"content":{"application/json":{"schema":{"example":{"code":413,"label":"sns-thread-budget-reached","message":"Too many concurrent calls to SNS; is SNS down?"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["sns-thread-budget-reached","token-too-long","metadata-too-long"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"sns-thread-budget-reached","message":"Too many concurrent calls to SNS; is SNS down?"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["sns-thread-budget-reached","token-too-long","metadata-too-long"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many concurrent calls to SNS; is SNS down? (label: `sns-thread-budget-reached`)\n\nPush token length must be < 8192 for GCM or 400 for APNS (label: `token-too-long`)\n\nTried to add token to endpoint resulting in metadata length > 2048 (label: `metadata-too-long`)"}},"summary":"Register a native push token"}},"/push/tokens/{pid}":{"delete":{"description":" [internal route ID: \"delete-push-token\"]\n\n","operationId":"delete-push-token","parameters":[{"description":"The push token to delete","in":"path","name":"pid","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Push token unregistered"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Push token not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Push token not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`pid` or Push token not found (label: `not-found`)"}},"summary":"Unregister a native push token"}},"/register":{"post":{"description":" [internal route ID: \"register\"]\n\nIf the environment where the registration takes place is private and a registered email address is not whitelisted, a 403 error is returned.","operationId":"register","parameters":[{"in":"header","name":"X-Forwarded-For","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewUser_PlainTextPassword_8_LTI4MzI5NzQx"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User_NjA4OTQwMTQ4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/User_NjA4OTQwMTQ4"}}},"description":"User created and pending activation","headers":{"Location":{"description":"UserId","schema":{"format":"uuid","type":"string"}},"Set-Cookie":{"description":"Cookie","schema":{"type":"string"}}}},"400":{"content":{"application/json":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code","invalid-email","invalid-phone"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code","invalid-email","invalid-phone"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid invitation code. (label: `invalid-invitation-code`)\n\nInvalid e-mail address. (label: `invalid-email`)\n\nInvalid mobile phone number (label: `invalid-phone`) or `body` or `X-Forwarded-For`"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unauthorized","message":"Unauthorized e-mail address"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorized","missing-identity","blacklisted-email","too-many-team-members","user-creation-restricted","ephemeral-user-creation-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorized","message":"Unauthorized e-mail address"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorized","missing-identity","blacklisted-email","too-many-team-members","user-creation-restricted","ephemeral-user-creation-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorized e-mail address (label: `unauthorized`)\n\nUsing an invitation code requires registering the given email. (label: `missing-identity`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nToo many members in this team. (label: `too-many-team-members`)\n\nThis instance does not allow creation of personal users or teams. (label: `user-creation-restricted`)\n\nEphemeral user creation is disabled on this instance. (label: `ephemeral-user-creation-disabled`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"invalid-code","message":"User does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"User does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"User does not exist (label: `invalid-code`)\n\nInvalid activation code (label: `invalid-code`)"},"409":{"content":{"application/json":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}},"summary":"Register a new user."}},"/scim/auth-tokens":{"delete":{"description":" [internal route ID: \"auth-tokens-delete\"]\n\n","operationId":"auth-tokens-delete","parameters":[{"in":"query","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}},"get":{"description":" [internal route ID: \"auth-tokens-list\"]\n\n","operationId":"auth-tokens-list","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ScimTokenList_NjQwNTYxOTAw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}},"post":{"description":" [internal route ID: \"auth-tokens-create\"]\n\n","operationId":"auth-tokens-create","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateScimToken_OTY0NjYxMDQ2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateScimTokenResponse_LTIzOTU2NDU4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}}},"/scim/auth-tokens/{id}":{"put":{"description":" [internal route ID: \"auth-tokens-put-name\"]\n\n","operationId":"auth-tokens-put-name","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ScimTokenName_LTgzOTM2OTI4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}}},"/search/contacts":{"get":{"description":" [internal route ID: \"search-contacts\"]\n\n","operationId":"search-contacts","parameters":[{"description":"Search query

The search query is normalized: lower-cased and diacritics are removed ('Björn' becomes 'bjorn'). The normalized search query matches accounts that, in this order of priority:

  • are equal to the normalized full handle;
  • are equal to the normalized full user display name;
  • prefix-match the normalized handle;
  • prefix-match the normalized user display name.

NB: '@' Does NOT do anything special, ignoring user display names.

See also: [authoritative ElasticSearch query](https://github.com/wireapp/wire-server/blob/83c25cca6a5e9d2205c102410b452eb78fc50a00/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs#L251-L288)

","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Searched domain. Note: This is optional only for backwards compatibility, future versions will mandate this.","in":"query","name":"domain","required":false,"schema":{"type":"string"}},{"description":"Number of results to return (min: 1, max: 500, default 15)","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":500,"minimum":1,"type":"integer"}},{"description":"Only user types. Omitted or empty (type=) means no filtering.","in":"query","name":"type","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SearchResult_Contact_OTExNzg4MTE0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `insufficient-permissions`)"}},"summary":"Search for users"}},"/self":{"delete":{"description":" [internal route ID: \"delete-self\"]\n\nif the account has a verified identity, a verification code is sent and needs to be confirmed to authorise the deletion. if the account has no verified identity but a password, it must be provided. if password is correct, or if neither a verified identity nor a password exists, account deletion is scheduled immediately.","operationId":"delete-self","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteUser_NjE0MjE2Mjkz"}}},"required":true},"responses":{"200":{"description":"Deletion is initiated."},"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionCodeTimeout_LTU1MTk0NDI3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeletionCodeTimeout_LTU1MTk0NDI3"}}},"description":"Deletion is pending verification with a code."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-user","message":"Invalid user"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-user"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid user (label: `invalid-user`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-self-delete-for-team-owner","message":"Team owners are not allowed to delete themselves; ask a fellow owner"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-self-delete-for-team-owner","pending-delete","missing-auth","invalid-credentials","invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team owners are not allowed to delete themselves; ask a fellow owner (label: `no-self-delete-for-team-owner`)\n\nA verification code for account deletion is still pending (label: `pending-delete`)\n\nRe-authentication via password required (label: `missing-auth`)\n\nAuthentication failed (label: `invalid-credentials`)\n\nInvalid verification code (label: `invalid-code`)"}},"summary":"Initiate account deletion."},"get":{"description":" [internal route ID: \"get-self\"]\n\n\nOAuth scope: `read:self`","operationId":"get-self","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/User_NjA4OTQwMTQ4"}}},"description":""}},"summary":"Get your own profile"},"put":{"description":" [internal route ID: \"put-self\"]\n\n","operationId":"put-self","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserUpdate_MjQ4NTEwOTQz"}}},"required":true},"responses":{"200":{"description":"User updated"}},"summary":"Update your profile."}},"/self/email":{"delete":{"description":" [internal route ID: \"remove-email\"]\n\nYour email address can only be removed if you also have a phone number.","operationId":"remove-email","responses":{"200":{"description":"Identity Removed"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"last-identity","message":"The last user identity cannot be removed."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["last-identity","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"last-identity","message":"The last user identity cannot be removed."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["last-identity","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The last user identity cannot be removed. (label: `last-identity`)\n\nThe user has no verified email (label: `no-identity`)"}},"summary":"Remove your email address."}},"/self/handle":{"put":{"description":" [internal route ID: \"change-handle\"]\n\n","operationId":"change-handle","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/HandleUpdate_NTI4NDk1OTAx"}}},"required":true},"responses":{"200":{"description":"Handle Changed"}},"summary":"Change your handle."}},"/self/locale":{"put":{"description":" [internal route ID: \"change-locale\"]\n\n","operationId":"change-locale","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LocaleUpdate_LTgzNjgyOTEw"}}},"required":true},"responses":{"200":{"description":"Local Changed"}},"summary":"Change your locale."}},"/self/password":{"head":{"description":" [internal route ID: \"check-password-exists\"]\n\n","operationId":"check-password-exists","responses":{"200":{"description":"Password is set"},"404":{"description":"Password is not set"}},"summary":"Check that your password is set."},"put":{"description":" [internal route ID: \"change-password\"]\n\n","operationId":"change-password","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordChange_MTgzMDM2NTY2"}}},"required":true},"responses":{"200":{"description":"Password Changed"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nThe user has no verified email (label: `no-identity`)"},"409":{"content":{"application/json":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password change, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password change, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password change, new and old password must be different. (label: `password-must-differ`)"}},"summary":"Change your password."}},"/self/supported-protocols":{"put":{"description":" [internal route ID: \"change-supported-protocols\"]\n\n","operationId":"change-supported-protocols","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SupportedProtocolUpdate_LTE3Njk3MDM4"}}},"required":true},"responses":{"200":{"description":"Supported protocols changed"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-protocol-error","message":"MLS protocol cannot be removed"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS protocol cannot be removed (label: `mls-protocol-error`)"}},"summary":"Change your supported protocols"}},"/services":{"get":{"description":" [internal route ID: \"get-services\"]\n\n","operationId":"get-services","parameters":[{"in":"query","name":"tags","required":false,"schema":{"enum":["audio","books","business","design","education","entertainment","finance","fitness","food-drink","games","graphics","health","integration","lifestyle","media","medical","movies","music","news","photography","poll","productivity","quiz","rating","shopping","social","sports","travel","tutorial","video","weather"],"type":"string"}},{"in":"query","name":"start","required":false,"schema":{"type":"string"}},{"in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":100,"minimum":10,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceProfilePage_Njg1NDQ5Njc4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"List services"}},"/services/tags":{"get":{"description":" [internal route ID: \"get-services-tags\"]\n\n","operationId":"get-services-tags","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceTagList"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}},"summary":"Get services tags"}},"/sso/finalize-login":{"post":{"deprecated":true,"description":" [internal route ID: \"auth-resp-legacy\"]\n\nDEPRECATED! use /sso/metadata/:tid instead! Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams","operationId":"auth-resp-legacy","responses":{"200":{"content":{"text/plain;charset=utf-8":{"schema":{"type":"string"}}},"description":""}}}},"/sso/finalize-login/{team}":{"post":{"description":" [internal route ID: \"auth-resp\"]\n\n","operationId":"auth-resp","parameters":[{"in":"path","name":"team","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/plain;charset=utf-8":{"schema":{"type":"string"}}},"description":""}}}},"/sso/get-by-email":{"post":{"description":" [internal route ID: \"sso-get-by-email\"]\n\n","operationId":"sso-get-by-email","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetByEmailReq_LTY4MzE3Njgy"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}}},"description":"SSO code found"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}}},"description":"SSO code not found or feature disabled"}}}},"/sso/initiate-login/{idp}":{"get":{"description":" [internal route ID: \"auth-req\"]\n\n","operationId":"auth-req","parameters":[{"in":"query","name":"success_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"error_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"label","required":false,"schema":{"type":"string"}},{"in":"path","name":"idp","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/html":{"schema":{"$ref":"#/components/schemas/FormRedirect"}}},"description":""}}},"head":{"description":" [internal route ID: \"auth-req-precheck\"]\n\n","operationId":"auth-req-precheck","parameters":[{"in":"query","name":"success_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"error_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"label","required":false,"schema":{"type":"string"}},{"in":"path","name":"idp","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/plain;charset=utf-8":{}},"description":""}}}},"/sso/metadata":{"get":{"deprecated":true,"description":" [internal route ID: \"sso-metadata\"]\n\nDEPRECATED! use /sso/metadata/:tid instead! Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams","operationId":"sso-metadata","responses":{"200":{"content":{"application/xml":{"schema":{"type":"string"}}},"description":""}}}},"/sso/metadata/{team}":{"get":{"description":" [internal route ID: \"sso-team-metadata\"]\n\n","operationId":"sso-team-metadata","parameters":[{"in":"path","name":"team","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/xml":{"schema":{"type":"string"}}},"description":""}}}},"/sso/settings":{"get":{"description":" [internal route ID: \"sso-settings\"]\n\n","operationId":"sso-settings","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SsoSettings"}}},"description":""}}}},"/system/settings":{"get":{"description":" [internal route ID: \"get-system-settings\"]\n\n","operationId":"get-system-settings","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SystemSettings_ODU3MDk5MTA3"}}},"description":""}},"summary":"Returns a curated set of system configuration settings for authorized users."}},"/system/settings/unauthorized":{"get":{"description":" [internal route ID: \"get-system-settings-unauthorized\"]\n\n","operationId":"get-system-settings-unauthorized","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SystemSettingsPublic_LTgwNTMxNjU2"}}},"description":""}},"summary":"Returns a curated set of system configuration settings."}},"/teams/invitations/accept":{"post":{"description":" [internal route ID: \"accept-team-invitation\"]\n\n","operationId":"accept-team-invitation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AcceptTeamInvitation_Nzg5NzI3MjA2"}}},"required":true},"responses":{"200":{"description":"Team invitation accepted."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-auth","message":"Re-authentication via password required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-auth","invalid-credentials","missing-identity","too-many-team-members"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Re-authentication via password required (label: `missing-auth`)\n\nAuthentication failed (label: `invalid-credentials`)\n\nUsing an invitation code requires registering the given email. (label: `missing-identity`)\n\nToo many members in this team. (label: `too-many-team-members`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"Invalid activation code"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code","not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)\n\nNo pending invitations exists. (label: `not-found`)"}},"summary":"Accept a team invitation, changing a personal account into a team member account."}},"/teams/invitations/by-email":{"head":{"description":" [internal route ID: \"head-team-invitations\"]\n\n","operationId":"head-team-invitations","parameters":[{"description":"Email address","in":"query","name":"email","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Pending invitation exists."},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"No pending invitations exists."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"No pending invitations exists."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"No pending invitations exists. (label: `not-found`)"},"409":{"content":{"application/json":{"schema":{"example":{"code":409,"label":"conflicting-invitations","message":"Multiple conflicting invitations to different teams exists."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["conflicting-invitations"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"conflicting-invitations","message":"Multiple conflicting invitations to different teams exists."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["conflicting-invitations"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Multiple conflicting invitations to different teams exists. (label: `conflicting-invitations`)"}},"summary":"Check if there is an invitation pending given an email address."}},"/teams/invitations/info":{"get":{"description":" [internal route ID: \"get-team-invitation-info\"]\n\n","operationId":"get-team-invitation-info","parameters":[{"description":"Invitation code","in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitationUserView_LTUyMTE3Nzkz"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InvitationUserView_LTUyMTE3Nzkz"}}},"description":"Invitation info"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `code`\n\nInvalid invitation code. (label: `invalid-invitation-code`)"}},"summary":"Get invitation info given a code."}},"/teams/notifications":{"get":{"description":" [internal route ID: \"get-team-notifications\"]\n\n

This is a work-around for scalability issues with gundeck user event fan-out. It does not track all team-wide events, but only `member-join`.

Note that `/teams/notifications` behaves differently from `/notifications`:

  • If there is a gap between the notification id requested with `since` and the available data, team queues respond with 200 and the data that could be found. They do NOT respond with status 404, but valid data in the body.\n
  • The notification with the id given via `since` is included in the response if it exists. You should remove this and only use it to decide whether there was a gap between your last request and this one.\n
  • If the notification id does *not* exist, you get the more recent events from the queue (instead of all of them). This can be done because a notification id is a UUIDv1, which is essentially a time stamp.\n
  • There is no corresponding `/last` end-point to get only the most recent event. That end-point was only useful to avoid having to pull the entire queue. In team queues, if you have never requested the queue before and have no prior notification id, just pull with timestamp 'now'.

See also: GET /notifications

","operationId":"get-team-notifications","parameters":[{"description":"Notification id to start with in the response (UUIDv1)","in":"query","name":"since","required":false,"schema":{"format":"uuid","type":"string"}},{"description":"Maximum number of events to return (1..10000; default: 1000)","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":10000,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotificationList_MTU0ODEyNTQ2"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-notification-id","message":"Could not parse notification id (must be UUIDv1)."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-notification-id"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `size` or `since`\n\nCould not parse notification id (must be UUIDv1). (label: `invalid-notification-id`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)"}},"summary":"Read recently added team members from team queue"}},"/teams/{team-id}/services/whitelist":{"post":{"description":" [internal route ID: \"post-team-whitelist-by-team-id\"]\n\n","operationId":"post-team-whitelist-by-team-id","parameters":[{"in":"path","name":"team-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateServiceWhitelist_LTU5MDAwMTIw"}}},"required":true},"responses":{"200":{"description":"UpdateServiceWhitelistRespChanged"},"204":{"description":"UpdateServiceWhitelistRespUnchanged"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-services-not-allowed","message":"Services not allowed in MLS"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-services-not-allowed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Services not allowed in MLS (label: `mls-services-not-allowed`)"}},"summary":"Update service whitelist"}},"/teams/{team-id}/services/whitelisted":{"get":{"description":" [internal route ID: \"get-whitelisted-services-by-team-id\"]\n\n","operationId":"get-whitelisted-services-by-team-id","parameters":[{"in":"path","name":"team-id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"prefix","required":false,"schema":{"maxLength":128,"minLength":1,"type":"string"}},{"in":"query","name":"filter_disabled","required":false,"schema":{"type":"boolean"}},{"in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":100,"minimum":10,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceProfilePage_Njg1NDQ5Njc4"}}},"description":""}},"summary":"Get whitelisted services by team id"}},"/teams/{teamId}/registered-domains":{"get":{"description":" [internal route ID: \"get-all-registered-domains\"]\n\n","operationId":"get-all-registered-domains","parameters":[{"in":"path","name":"teamId","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RegisteredDomains_V10_NDYwNzYyMTMy"}}},"description":""}},"summary":"Get all registered domains"}},"/teams/{teamId}/registered-domains/{domain}":{"delete":{"description":" [internal route ID: \"delete-registered-domain\"]\n\n","operationId":"delete-registered-domain","parameters":[{"in":"path","name":"teamId","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}},"summary":"Delete a registered domain"}},"/teams/{tid}":{"delete":{"description":" [internal route ID: \"delete-team\"]\n\n","operationId":"delete-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamDeleteData_ODI5NTU0ODE5"}}},"required":true},"responses":{"202":{"description":"Team is scheduled for removal"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Verification code required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed","access-denied","operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Verification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (missing DeleteTeam) (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"503":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":503,"label":"queue-full","message":"The delete queue is full; no further delete requests can be processed at the moment"},"properties":{"code":{"enum":[503],"type":"integer"},"label":{"enum":["queue-full"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The delete queue is full; no further delete requests can be processed at the moment (label: `queue-full`)"}},"summary":"Delete a team"},"get":{"description":" [internal route ID: \"get-team\"]\n\n","operationId":"get-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Team_NDg4MjQwOTIw"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get a team by ID"},"put":{"description":" [internal route ID: \"update-team\"]\n\n","operationId":"update-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamUpdateData_LTE0NTM2NTU5"}}},"required":true},"responses":{"200":{"description":"Team updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions (missing SetTeamData)"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (missing SetTeamData) (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}},"summary":"Update team properties"}},"/teams/{tid}/apps":{"get":{"description":" [internal route ID: \"get-apps\"]\n\n","operationId":"get-apps","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"},"type":"array"}}},"description":""}},"summary":"Get all apps owned by the given team (not including collaborators)"},"post":{"description":" [internal route ID: \"create-app\"]\n\n","operationId":"create-app","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewApp_LTQwODMwMzQ4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreatedApp_LTM3NjUxOTY1"}}},"description":""}},"summary":"Create a new app"}},"/teams/{tid}/apps/{app}":{"put":{"description":" [internal route ID: \"put-app\"]\n\n","operationId":"put-app","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"app","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PutApp_LTE4MDc1OTM4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""}},"summary":"Update metadata of an existing app"}},"/teams/{tid}/apps/{app}/cookies":{"post":{"description":" [internal route ID: \"refresh-app-cookie\"]\n\n","operationId":"refresh-app-cookie","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"app","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RefreshAppCookieRequest_MjEyMDMyMTk5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RefreshAppCookieResponse_LTQ0MjU1NTIw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-auth","message":"Re-authentication via password required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-auth"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Re-authentication via password required (label: `missing-auth`)"}},"summary":"Get a new app authentication token"}},"/teams/{tid}/channels/search":{"get":{"description":" [internal route ID: \"search-channels\"]\n\n","operationId":"search-channels","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Search string","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"in":"query","name":"sort_order","required":false,"schema":{"enum":["asc","desc"],"type":"string"}},{"in":"query","name":"page_size","required":false,"schema":{"description":"integer from [1..500]","type":"number"}},{"description":"`name` of the last seen channel of the current page, used to get the next page.","in":"query","name":"last_seen_name","required":false,"schema":{"type":"string"}},{"description":"`id` of the last seen channel, used to get the next page, used as a tie breaker. **Must** be sent to get the next page.","in":"query","name":"last_seen_id","required":false,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"discoverable","schema":{"default":false,"type":"boolean"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationPage_LTIwMDU2NDI3"}}},"description":""}},"summary":"Search channels"}},"/teams/{tid}/collaborators":{"get":{"description":" [internal route ID: \"get-team-collaborators\"]\n\n","operationId":"get-team-collaborators","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/TeamCollaborator_LTI3MzM1MTYz"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/TeamCollaborator_LTI3MzM1MTYz"},"type":"array"}}},"description":"Return collaborators"}},"summary":"Get all collaborators of the team."},"post":{"description":" [internal route ID: \"add-team-collaborator\"]\n\n","operationId":"add-team-collaborator","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewTeamCollaborator_LTIxNjEzMTYw"}}},"required":true},"responses":{"200":{"description":""}},"summary":"Add a collaborator to the team."}},"/teams/{tid}/collaborators/{uid}":{"delete":{"description":" [internal route ID: \"remove-team-collaborator\"]\n\n","operationId":"remove-team-collaborator","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"}},"summary":"Remove a collaborator from the team."},"put":{"description":" [internal route ID: \"update-team-collaborator\"]\n\n","operationId":"update-team-collaborator","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/CollaboratorPermission_NDg5NTg2ODgy"},"type":"array","uniqueItems":true}}},"required":true},"responses":{"200":{"description":""}},"summary":"Update a collaborator permissions from the team."}},"/teams/{tid}/conversations":{"get":{"description":" [internal route ID: \"get-team-conversations\"]\n\n","operationId":"get-team-conversations","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamConversationList_OTI3MzY3NzY0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"}},"summary":"Get team conversations"}},"/teams/{tid}/conversations/roles":{"get":{"description":" [internal route ID: \"get-team-conversation-roles\"]\n\n","operationId":"get-team-conversation-roles","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationRolesList"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"}},"summary":"Get existing roles available for the given team"}},"/teams/{tid}/conversations/{cid}":{"delete":{"description":" [internal route ID: \"delete-team-conversation\"]\n\n","operationId":"delete-team-conversation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"cid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"Conversation deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing delete_conversation) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `cid` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Remove a team conversation"},"get":{"description":" [internal route ID: \"get-team-conversation\"]\n\n","operationId":"get-team-conversation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"cid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamConversation_LTIwNzgyNTEz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `cid` not found\n\nConversation not found (label: `no-conversation`)"}},"summary":"Get one team conversation"}},"/teams/{tid}/features":{"get":{"description":" [internal route ID: \"get-all-feature-configs-for-team\"]\n\nGets feature configs for a team. User must be a member of the team and have permission to view team features.","operationId":"get-all-feature-configs-for-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NP_LockableFtur_ghdCnfiSOVsyAvIRqExmDpB_MGwUT2Q_LTM1Mzc0ODgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Gets feature configs for a team"}},"/teams/{tid}/features/allowedGlobalOperations":{"get":{"description":" [internal route ID: (\"get\", AllowedGlobalOperationsConfig)]\n\n","operationId":"get_AllowedGlobalOperationsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AllowedGlobalOperationsConfig_NjQ1MjA5MDYw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for allowedGlobalOperations"}},"/teams/{tid}/features/appLock":{"get":{"description":" [internal route ID: (\"get\", AppLockConfigB)]\n\n","operationId":"get_AppLockConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for appLock"},"put":{"description":" [internal route ID: (\"put\", AppLockConfigB)]\n\n","operationId":"put_AppLockConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_AppLockConfigB_Bare_Identity_MTAzMjI5NDYy"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for appLock"}},"/teams/{tid}/features/apps":{"get":{"description":" [internal route ID: (\"get\", AppsConfig)]\n\n","operationId":"get_AppsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AppsConfig_MzQyNTMxNTk5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for apps"}},"/teams/{tid}/features/assetAuditLog":{"get":{"description":" [internal route ID: (\"get\", AssetAuditLogConfig)]\n\n","operationId":"get_AssetAuditLogConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AssetAuditLogConfig_NDQ3MzcyMzk2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for assetAuditLog"}},"/teams/{tid}/features/backgroundEffects":{"get":{"description":" [internal route ID: (\"get\", BackgroundEffectsConfig)]\n\n","operationId":"get_BackgroundEffectsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_BackgroundEffectsConfig_MTg1MTk3NTM5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for backgroundEffects"},"put":{"description":" [internal route ID: (\"put\", BackgroundEffectsConfig)]\n\n","operationId":"put_BackgroundEffectsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_BackgroundEffectsConfig_MjQyOTkxMDc4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_BackgroundEffectsConfig_MTg1MTk3NTM5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for backgroundEffects"}},"/teams/{tid}/features/cells":{"get":{"description":" [internal route ID: (\"get\", CellsConfigB)]\n\n","operationId":"get_CellsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for cells"},"put":{"description":" [internal route ID: (\"put\", CellsConfigB)]\n\n","operationId":"put_CellsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_CellsConfigB_Bare_Identity_NzU2NjgxNzEw"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for cells"}},"/teams/{tid}/features/cellsInternal":{"get":{"description":" [internal route ID: (\"get\", CellsInternalConfigB)]\n\n","operationId":"get_CellsInternalConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_CsInfigBdy_LTQ3MTU3Mjg0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for cellsInternal"}},"/teams/{tid}/features/channels":{"get":{"description":" [internal route ID: (\"get\", ChannelsConfigB)]\n\n","operationId":"get_ChannelsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for channels"},"put":{"description":" [internal route ID: (\"put\", ChannelsConfigB)]\n\n","operationId":"put_ChannelsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_ChannelsConfigB_Bare_Identity_LTU4NTE4MTgx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for channels"}},"/teams/{tid}/features/chatBubbles":{"get":{"description":" [internal route ID: (\"get\", ChatBubblesConfig)]\n\n","operationId":"get_ChatBubblesConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ChatBubblesConfig_NDgwMTQzNjI2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for chatBubbles"}},"/teams/{tid}/features/classifiedDomains":{"get":{"description":" [internal route ID: (\"get\", ClassifiedDomainsConfig)]\n\n","operationId":"get_ClassifiedDomainsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ClassifiedDomainsConfig_LTY1ODQwODg1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for classifiedDomains"}},"/teams/{tid}/features/conferenceCalling":{"get":{"description":" [internal route ID: (\"get\", ConferenceCallingConfigB)]\n\n","operationId":"get_ConferenceCallingConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_CnfigBIdy_NzY1NDU5MDAy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for conferenceCalling"},"put":{"description":" [internal route ID: (\"put\", ConferenceCallingConfigB)]\n\n","operationId":"put_ConferenceCallingConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_ConferenceCallingConfigB_Bare_Identity_Njc2NTcxNTI3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_CnfigBIdy_NzY1NDU5MDAy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for conferenceCalling"}},"/teams/{tid}/features/consumableNotifications":{"get":{"description":" [internal route ID: (\"get\", ConsumableNotificationsConfig)]\n\n","operationId":"get_ConsumableNotificationsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ConsumableNotificationsConfig_MjUxMjczMjM0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for consumableNotifications"}},"/teams/{tid}/features/conversationGuestLinks":{"get":{"description":" [internal route ID: (\"get\", GuestLinksConfig)]\n\n","operationId":"get_GuestLinksConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for conversationGuestLinks"},"put":{"description":" [internal route ID: (\"put\", GuestLinksConfig)]\n\n","operationId":"put_GuestLinksConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_GuestLinksConfig_NjQyMDMxNjg3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for conversationGuestLinks"}},"/teams/{tid}/features/digitalSignatures":{"get":{"description":" [internal route ID: (\"get\", DigitalSignaturesConfig)]\n\n","operationId":"get_DigitalSignaturesConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_DigitalSignaturesConfig_ODM2MDA2NDU4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for digitalSignatures"}},"/teams/{tid}/features/domainRegistration":{"get":{"description":" [internal route ID: (\"get\", DomainRegistrationConfig)]\n\n","operationId":"get_DomainRegistrationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_DomainRegistrationConfig_NzU0NjczNTE0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for domainRegistration"}},"/teams/{tid}/features/enforceFileDownloadLocation":{"get":{"description":" [internal route ID: (\"get\", EnforceFileDownloadLocationConfigB)]\n\n

Custom feature: only supported on some dedicated on-prem systems.

","operationId":"get_EnforceFileDownloadLocationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for enforceFileDownloadLocation"},"put":{"description":" [internal route ID: (\"put\", EnforceFileDownloadLocationConfigB)]\n\n

Custom feature: only supported on some dedicated on-prem systems.

","operationId":"put_EnforceFileDownloadLocationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Featur_EnfocilDwdLCgBIy_MjcxMzc1NDA5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for enforceFileDownloadLocation"}},"/teams/{tid}/features/exposeInvitationURLsToTeamAdmin":{"get":{"description":" [internal route ID: (\"get\", ExposeInvitationURLsToTeamAdminConfig)]\n\n","operationId":"get_ExposeInvitationURLsToTeamAdminConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for exposeInvitationURLsToTeamAdmin"},"put":{"description":" [internal route ID: (\"put\", ExposeInvitationURLsToTeamAdminConfig)]\n\n","operationId":"put_ExposeInvitationURLsToTeamAdminConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_ExposeInvitationURLsToTeamAdminConfig_LTY5NzY2Mzg5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for exposeInvitationURLsToTeamAdmin"}},"/teams/{tid}/features/fileSharing":{"get":{"description":" [internal route ID: (\"get\", FileSharingConfig)]\n\n","operationId":"get_FileSharingConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_FileSharingConfig_MjgwNjIzODEz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for fileSharing"},"put":{"description":" [internal route ID: (\"put\", FileSharingConfig)]\n\n","operationId":"put_FileSharingConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_FileSharingConfig_LTUyNjkxMzM4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_FileSharingConfig_MjgwNjIzODEz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for fileSharing"}},"/teams/{tid}/features/legalhold":{"get":{"description":" [internal route ID: (\"get\", LegalholdConfig)]\n\n","operationId":"get_LegalholdConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_LegalholdConfig_LTc5MTk5OTIw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for legalhold"},"put":{"description":" [internal route ID: (\"put\", LegalholdConfig)]\n\n","operationId":"put_LegalholdConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_LegalholdConfig_NjM3MTkxNjYw"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_LegalholdConfig_LTc5MTk5OTIw"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-disable-unimplemented","message":"legal hold cannot be disabled for whitelisted teams"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-disable-unimplemented","legalhold-not-enabled","too-large-team-for-legalhold","action-denied","no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nCannot enable legalhold on large teams (reason: for removing LH from team, we need to iterate over all members, which is only supported for teams with less than 2k members) (label: `too-large-team-for-legalhold`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}},"summary":"Put config for legalhold"}},"/teams/{tid}/features/limitedEventFanout":{"get":{"description":" [internal route ID: (\"get\", LimitedEventFanoutConfig)]\n\n","operationId":"get_LimitedEventFanoutConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_LimitedEventFanoutConfig_MTg3ODM0NzU0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for limitedEventFanout"}},"/teams/{tid}/features/meetings":{"get":{"description":" [internal route ID: (\"get\", MeetingsConfig)]\n\n","operationId":"get_MeetingsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for meetings"},"put":{"description":" [internal route ID: (\"put\", MeetingsConfig)]\n\n","operationId":"put_MeetingsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MeetingsConfig_NDc2MzM0MDE1"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for meetings"}},"/teams/{tid}/features/meetingsPremium":{"get":{"description":" [internal route ID: (\"get\", MeetingsPremiumConfig)]\n\n","operationId":"get_MeetingsPremiumConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MeetingsPremiumConfig_NDg1ODEyNTA1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for meetingsPremium"},"put":{"description":" [internal route ID: (\"put\", MeetingsPremiumConfig)]\n\n","operationId":"put_MeetingsPremiumConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MeetingsPremiumConfig_NzE4NjUzMDE0"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MeetingsPremiumConfig_NDg1ODEyNTA1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for meetingsPremium"}},"/teams/{tid}/features/mls":{"get":{"description":" [internal route ID: (\"get\", MLSConfigB)]\n\n","operationId":"get_MLSConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for mls"},"put":{"description":" [internal route ID: (\"put\", MLSConfigB)]\n\n","operationId":"put_MLSConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MLSConfigB_Bare_Identity_LTI5MjA3MDYy"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for mls"}},"/teams/{tid}/features/mlsE2EId":{"get":{"description":" [internal route ID: (\"get\", MlsE2EIdConfigB)]\n\n","operationId":"get_MlsE2EIdConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for mlsE2EId"},"put":{"description":" [internal route ID: (\"put\", MlsE2EIdConfigB)]\n\n","operationId":"put_MlsE2EIdConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MlsE2EIdConfigB_Bare_Identity_LTUxODYzODEx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for mlsE2EId"}},"/teams/{tid}/features/mlsMigration":{"get":{"description":" [internal route ID: (\"get\", MlsMigrationConfigB)]\n\n","operationId":"get_MlsMigrationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_MsignCfBIdy_LTE1NjAxNjU2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for mlsMigration"},"put":{"description":" [internal route ID: (\"put\", MlsMigrationConfigB)]\n\n","operationId":"put_MlsMigrationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MlsMigrationConfigB_Bare_Identity_LTQyMDAxMTkz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_MsignCfBIdy_LTE1NjAxNjU2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for mlsMigration"}},"/teams/{tid}/features/outlookCalIntegration":{"get":{"description":" [internal route ID: (\"get\", OutlookCalIntegrationConfig)]\n\n","operationId":"get_OutlookCalIntegrationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for outlookCalIntegration"},"put":{"description":" [internal route ID: (\"put\", OutlookCalIntegrationConfig)]\n\n","operationId":"put_OutlookCalIntegrationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_OutlookCalIntegrationConfig_LTg0MjIxNTMx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for outlookCalIntegration"}},"/teams/{tid}/features/preventAdminlessGroups":{"get":{"description":" [internal route ID: (\"get\", PreventAdminlessGroupsConfigB)]\n\n","operationId":"get_PreventAdminlessGroupsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for preventAdminlessGroups"},"put":{"description":" [internal route ID: (\"put\", PreventAdminlessGroupsConfigB)]\n\n","operationId":"put_PreventAdminlessGroupsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Featur_PvnAdmilsGopCfgBIy_LTE2NzM3ODkx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for preventAdminlessGroups"}},"/teams/{tid}/features/searchVisibility":{"get":{"description":" [internal route ID: (\"get\", SearchVisibilityAvailableConfig)]\n\n","operationId":"get_SearchVisibilityAvailableConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for searchVisibility"},"put":{"description":" [internal route ID: (\"put\", SearchVisibilityAvailableConfig)]\n\n","operationId":"put_SearchVisibilityAvailableConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_SearchVisibilityAvailableConfig_LTMzNTkxODI1"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for searchVisibility"}},"/teams/{tid}/features/searchVisibilityInbound":{"get":{"description":" [internal route ID: (\"get\", SearchVisibilityInboundConfig)]\n\n","operationId":"get_SearchVisibilityInboundConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for searchVisibilityInbound"},"put":{"description":" [internal route ID: (\"put\", SearchVisibilityInboundConfig)]\n\n","operationId":"put_SearchVisibilityInboundConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_SearchVisibilityInboundConfig_MTI1NzQxODY2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for searchVisibilityInbound"}},"/teams/{tid}/features/selfDeletingMessages":{"get":{"description":" [internal route ID: (\"get\", SelfDeletingMessagesConfigB)]\n\n","operationId":"get_SelfDeletingMessagesConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for selfDeletingMessages"},"put":{"description":" [internal route ID: (\"put\", SelfDeletingMessagesConfigB)]\n\n","operationId":"put_SelfDeletingMessagesConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Featur_SlfDingMsCoBIdy_LTg2MzYzNjc2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for selfDeletingMessages"}},"/teams/{tid}/features/simplifiedUserConnectionRequestQRCode":{"get":{"description":" [internal route ID: (\"get\", SimplifiedUserConnectionRequestQRCodeConfig)]\n\n","operationId":"get_SimplifiedUserConnectionRequestQRCodeConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_SimpfdUsCnRqQg_Njk5NjU4OTgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for simplifiedUserConnectionRequestQRCode"}},"/teams/{tid}/features/sndFactorPasswordChallenge":{"get":{"description":" [internal route ID: (\"get\", SndFactorPasswordChallengeConfig)]\n\n","operationId":"get_SndFactorPasswordChallengeConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for sndFactorPasswordChallenge"},"put":{"description":" [internal route ID: (\"put\", SndFactorPasswordChallengeConfig)]\n\n","operationId":"put_SndFactorPasswordChallengeConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_SndFactorPasswordChallengeConfig_NDc0MzUyMzQz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Put config for sndFactorPasswordChallenge"}},"/teams/{tid}/features/sso":{"get":{"description":" [internal route ID: (\"get\", SSOConfig)]\n\n","operationId":"get_SSOConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SSOConfig_NjcyMjU4MDY2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for sso"}},"/teams/{tid}/features/stealthUsers":{"get":{"description":" [internal route ID: (\"get\", StealthUsersConfig)]\n\n","operationId":"get_StealthUsersConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_StealthUsersConfig_LTE1MTk2NzIz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for stealthUsers"}},"/teams/{tid}/features/validateSAMLemails":{"get":{"description":" [internal route ID: (\"get\", RequireExternalEmailVerificationConfig)]\n\n

Controls whether externally managed email addresses (from SAML or SCIM) must be verified by the user, or are auto-activated.

The external feature name is kept as validateSAMLemails for backward compatibility. That name is misleading because the feature also applies to SCIM-managed users, and it controls email ownership verification rather than generic email validation.

","operationId":"get_RequireExternalEmailVerificationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_RqiExnmVfCg_NjUyMzgzNzY5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for validateSAMLemails"}},"/teams/{tid}/get-members-by-ids-using-post":{"post":{"description":" [internal route ID: \"get-team-members-by-ids\"]\n\nThe `has_more` field in the response body is always `false`.","operationId":"get-team-members-by-ids","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Maximum results to be returned","in":"query","name":"maxResults","required":false,"schema":{"format":"int32","maximum":2000,"minimum":1,"type":"integer"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserIdList_MzA1MTI1Njgx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMemberList_Optional_LTM1ODE2MzM0"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"too-many-uids","message":"Can only process 2000 user ids per request."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["too-many-uids"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `maxResults`\n\nCan only process 2000 user ids per request. (label: `too-many-uids`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"}},"summary":"Get team members by user id list"}},"/teams/{tid}/invitations":{"get":{"description":" [internal route ID: \"get-team-invitations\"]\n\n","operationId":"get-team-invitations","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Invitation id to start from (ascending).","in":"query","name":"start","required":false,"schema":{"format":"uuid","type":"string"}},{"description":"Number of results to return (default 100, max 500).","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":500,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitationList_ODk4NTQxODc3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InvitationList_ODk4NTQxODc3"}}},"description":"List of sent invitations"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}},"summary":"List the sent team invitations"},"post":{"description":" [internal route ID: \"send-team-invitation\"]\n\nInvitations are sent by email. The maximum allowed number of pending team invitations is equal to the team size.","operationId":"send-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InvitationRequest_LTcyMDIzNDc0"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}}},"description":"Invitation was created and sent.","headers":{"Location":{"schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code","invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid invitation code. (label: `invalid-invitation-code`)\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions","too-many-team-invitations","blacklisted-email","no-identity","no-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)\n\nToo many team invitations for this team (label: `too-many-team-invitations`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nThe user has no verified email (label: `no-identity`)\n\nThis operation requires the user to have a verified email address. (label: `no-email`)"}},"summary":"Create and send a new team invitation."}},"/teams/{tid}/invitations/{iid}":{"delete":{"description":" [internal route ID: \"delete-team-invitation\"]\n\n","operationId":"delete-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"iid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"Invitation deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}},"summary":"Delete a pending team invitation by ID."},"get":{"description":" [internal route ID: \"get-team-invitation\"]\n\n","operationId":"get-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"iid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}}},"description":"Invitation"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Notification not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Notification not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `iid` or Notification not found. (label: `not-found`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"duplicate-entry","message":"Entry already exists"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["duplicate-entry"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Entry already exists (label: `duplicate-entry`)"}},"summary":"Get a pending team invitation by ID."}},"/teams/{tid}/legalhold/consent":{"post":{"description":" [internal route ID: \"consent-to-legal-hold\"]\n\n","operationId":"consent-to-legal-hold","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"201":{"description":"Grant consent successful"},"204":{"description":"Consent already granted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam member not found (label: `no-team-member`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}},"summary":"Consent to legal hold"}},"/teams/{tid}/legalhold/settings":{"delete":{"description":" [internal route ID: \"delete-legal-hold-settings\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to members with a legalhold client (via brig)\n- UserLegalHoldDisabled event to contacts of members with a legalhold client (via brig)","operationId":"delete-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RemoveLegalHoldSettingsRequest_NzMzMzMyMDAz"}}},"required":true},"responses":{"204":{"description":"Legal hold service settings deleted"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-disable-unimplemented","message":"legal hold cannot be disabled for whitelisted teams"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-disable-unimplemented","legalhold-not-enabled","invalid-op","action-denied","no-team-member","operation-denied","code-authentication-required","code-authentication-failed","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}},"summary":"Delete legal hold service settings"},"get":{"description":" [internal route ID: \"get-legal-hold-settings\"]\n\n","operationId":"get-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}},"summary":"Get legal hold service settings"},"post":{"description":" [internal route ID: \"create-legal-hold-settings\"]\n\n","operationId":"create-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewLegalHoldService_Mzg0ODQ5NDU1"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}}},"description":"Legal hold service settings created"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-status-bad","message":"legal hold service: invalid response"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-status-bad","legalhold-invalid-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)\n\nlegal hold service pubkey is invalid (label: `legalhold-invalid-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}},"summary":"Create legal hold service settings"}},"/teams/{tid}/legalhold/{uid}":{"delete":{"description":" [internal route ID: \"disable-legal-hold-for-user\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to the user owning the client (via brig)\n- UserLegalHoldDisabled event to contacts of the user owning the client (via brig)","operationId":"disable-legal-hold-for-user","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DisableLegalHoldForUserRequest_LTYyMDYxOTEy"}}},"required":true},"responses":{"200":{"description":"Disable legal hold successful"},"204":{"description":"Legal hold was not enabled"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","action-denied","code-authentication-required","code-authentication-failed","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}},"summary":"Disable legal hold for user"},"get":{"description":" [internal route ID: \"get-legal-hold\"]\n\n","operationId":"get-legal-hold","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserLegalHoldStatusResponse_LTQ1MzUxMTE3"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"}},"summary":"Get legal hold status"},"post":{"description":" [internal route ID: \"request-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- LegalHoldClientRequested event to contacts of the user the device is requested for, if they didn't already have a legalhold client (via brig)","operationId":"request-legal-hold-device","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"201":{"description":"Request device successful"},"204":{"description":"Request device already pending"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered","legalhold-status-bad"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service has not been registered for this team (label: `legalhold-not-registered`)\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","operation-denied","no-team-member","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-legal-hold-not-allowed","message":"A user who is under legal-hold may not participate in MLS conversations"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-legal-hold-not-allowed","legalhold-no-consent","legalhold-already-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"A user who is under legal-hold may not participate in MLS conversations (label: `mls-legal-hold-not-allowed`)\n\nuser has not given consent to using legal hold (label: `legalhold-no-consent`)\n\nlegal hold is already enabled for this user (label: `legalhold-already-enabled`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-illegal-op","message":"internal server error: inconsistent change of user's legalhold state"},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-illegal-op","legalhold-internal"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"internal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)\n\nlegal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)"}},"summary":"Request legal hold device"}},"/teams/{tid}/legalhold/{uid}/approve":{"put":{"description":" [internal route ID: \"approve-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientAdded event to the user owning the client (via brig)\n- UserLegalHoldEnabled event to contacts of the user owning the client (via brig)\n- ClientRemoved event to the user, if removing old client due to max number (via brig)","operationId":"approve-legal-hold-device","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ApproveLegalHoldForUserRequest_NjEyNzYyMTIx"}}},"required":true},"responses":{"200":{"description":"Legal hold approved"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","no-team-member","action-denied","access-denied","code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"legalhold-no-device-allocated","message":"no legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["legalhold-no-device-allocated"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nno legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow. (label: `legalhold-no-device-allocated`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"legalhold-already-enabled","message":"legal hold is already enabled for this user"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["legalhold-already-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is already enabled for this user (label: `legalhold-already-enabled`)"},"412":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":412,"label":"legalhold-not-pending","message":"legal hold cannot be approved without being in a pending state"},"properties":{"code":{"enum":[412],"type":"integer"},"label":{"enum":["legalhold-not-pending"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold cannot be approved without being in a pending state (label: `legalhold-not-pending`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}},"summary":"Approve legal hold device"}},"/teams/{tid}/members":{"get":{"description":" [internal route ID: \"get-team-members\"]\n\n","operationId":"get-team-members","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Maximum results to be returned","in":"query","name":"maxResults","required":false,"schema":{"format":"int32","maximum":2000,"minimum":1,"type":"integer"}},{"description":"Optional, when not specified, the first page will be returned.Every returned page contains a `pagingState`, this should be supplied to retrieve the next page.","in":"query","name":"pagingState","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMembersPage_NzYwNDIxODgx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"}},"summary":"Get team members"},"put":{"description":" [internal route ID: \"update-team-member\"]\n\n","operationId":"update-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewTeamMember_Required_LTg2NjU5OTI2"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","too-many-team-admins","invalid-permissions","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nMaximum number of admins per team reached (label: `too-many-team-admins`)\n\nThe specified permissions are invalid (label: `invalid-permissions`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member","no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam member not found (label: `no-team-member`)\n\nTeam not found (label: `no-team`)"}},"summary":"Update an existing team member"}},"/teams/{tid}/members/csv":{"get":{"description":" [internal route ID: \"get-team-members-csv\"]\n\nThe endpoint returns data in chunked transfer encoding. Internal server errors might result in a failed transfer instead of a 500 response.","operationId":"get-team-members-csv","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/csv":{}},"description":"CSV of team members"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"You do not have permission to access this resource"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"You do not have permission to access this resource (label: `access-denied`)"}},"summary":"Get all members of the team as a CSV file"}},"/teams/{tid}/members/{uid}":{"delete":{"description":" [internal route ID: \"delete-team-member\"]\n\n","operationId":"delete-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMemberDeleteData_LTg2OTEyOTI4"}}},"required":true},"responses":{"200":{"description":""},"202":{"description":"Team member scheduled for deletion"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","access-denied","code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam not found (label: `no-team`)\n\nTeam member not found (label: `no-team-member`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"}},"summary":"Remove an existing team member"},"get":{"description":" [internal route ID: \"get-team-member\"]\n\n","operationId":"get-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMember_Optional_NTU0MDcyNzI1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"}},"summary":"Get single team member"}},"/teams/{tid}/search":{"get":{"description":" [internal route ID: \"browse-team\"]\n\n","operationId":"browse-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Search expression","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"description":"Role filter, eg. `member,partner`. Empty list means do not filter.","in":"query","name":"frole","required":false,"schema":{"items":{"enum":["owner","admin","member","partner"],"type":"string"},"type":"array"}},{"description":"Can be one of name, handle, email, saml_idp, managed_by, role, created_at.","in":"query","name":"sortby","required":false,"schema":{"enum":["name","handle","email","saml_idp","managed_by","role","created_at"],"type":"string"}},{"description":"Can be one of asc, desc.","in":"query","name":"sortorder","required":false,"schema":{"enum":["asc","desc"],"type":"string"}},{"description":"Number of results to return (min: 1, max: 500, default: 15)","in":"query","name":"size","required":false,"schema":{"maximum":500,"minimum":1,"type":"integer"}},{"description":"Optional, when not specified, the first page will be returned. Every returned page contains a `paging_state`, this should be supplied to retrieve the next page.","in":"query","name":"pagingState","required":false,"schema":{"type":"string"}},{"description":"Filter for (un-)verified email","in":"query","name":"email","required":false,"schema":{"enum":["unverified","verified"],"type":"string"}},{"description":"Optional, return only non-searchable members when false.","in":"query","name":"searchable","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResult_TeamContact_LTE0NjQ0NzMw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SearchResult_TeamContact_LTE0NjQ0NzMw"}}},"description":"Search results"}},"summary":"Browse team for members (requires add-user permission)"}},"/teams/{tid}/search-visibility":{"get":{"description":" [internal route ID: \"get-search-visibility\"]\n\n","operationId":"get-search-visibility","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSearchVisibilityView_Mzg3MzMzMTk3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}},"summary":"Shows the value for search visibility"},"put":{"description":" [internal route ID: \"set-search-visibility\"]\n\n","operationId":"set-search-visibility","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSearchVisibilityView_Mzg3MzMzMTk3"}}},"required":true},"responses":{"204":{"description":"Search visibility set"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"team-search-visibility-not-enabled","message":"Custom search is not available for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["team-search-visibility-not-enabled","operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Custom search is not available for this team (label: `team-search-visibility-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Sets the search visibility for the whole team"}},"/teams/{tid}/size":{"get":{"description":" [internal route ID: \"get-team-size\"]\n\nCan be out of sync by roughly the `refresh_interval` of the ES index.","operationId":"get-team-size","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamSize_LTMzMzk2MTk1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSize_LTMzMzk2MTk1"}}},"description":"Number of team members"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid invitation code. (label: `invalid-invitation-code`)"}},"summary":"Get the number of team members as an integer"}},"/time":{"get":{"description":" [internal route ID: \"get-server-time\"]\n\nReturns the current server time in UTC with seconds precision.","operationId":"get-server-time","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServerTime_LTM4NTI3MzIx"}}},"description":""}},"summary":"Get the current server time"}},"/upgrade-personal-to-team":{"post":{"description":" [internal route ID: \"upgrade-personal-to-team\"]\n\n","operationId":"upgrade-personal-to-team","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/BindingNewTeamUser_LTY0MDQxMDEw"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserTeam_MzI4NDQ1Mzkw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateUserTeam_MzI4NDQ1Mzkw"}}},"description":"Team created"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"user-already-in-a-team","message":"Switching teams is not allowed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-already-in-a-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-already-in-a-team","message":"Switching teams is not allowed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-already-in-a-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Switching teams is not allowed (label: `user-already-in-a-team`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"User not found (label: `not-found`)"}},"summary":"Upgrade personal user to team owner"}},"/user-groups":{"get":{"description":" [internal route ID: \"get-user-groups\"]\n\n","operationId":"get-user-groups","parameters":[{"description":"Search string","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"in":"query","name":"sort_by","required":false,"schema":{"enum":["name","created_at"],"type":"string"}},{"in":"query","name":"sort_order","required":false,"schema":{"enum":["asc","desc"],"type":"string"}},{"in":"query","name":"page_size","required":false,"schema":{"description":"integer from [1..500]","type":"number"}},{"description":"`name` of the last seen user group, used to get the next page when sorting by name.","in":"query","name":"last_seen_name","required":false,"schema":{"maxLength":4000,"minLength":1,"type":"string"}},{"description":"`created_at` field of the last seen user group, used to get the next page when sorting by created_at.","in":"query","name":"last_seen_created_at","required":false,"schema":{"format":"yyyy-mm-ddThh:MM:ssZ","type":"string"}},{"description":"`id` of the last seen group, used to get the next page. **Must** be sent to get the next page.","in":"query","name":"last_seen_id","required":false,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"include_channels","schema":{"default":false,"type":"boolean"}},{"allowEmptyValue":true,"in":"query","name":"include_member_count","schema":{"default":false,"type":"boolean"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupPage_UserGroup_Const_LTMxNDg5MDAy"}}},"description":""}},"summary":"Fetch groups accessible to the logged-in user"},"post":{"description":" [internal route ID: \"create-user-group\"]\n\n","operationId":"create-user-group","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewUserGroup_MzYxODU0OTU1"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nOnly team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"}}}},"/user-groups/check-name":{"post":{"description":" [internal route ID: \"check-user-group-name-available\"]\n\n","operationId":"check-user-group-name-available","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CheckUserGroupName_LTg0ODU1OTk1"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserGroupNameAvailability_LTYzMDE1NTk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupNameAvailability_LTYzMDE1NTk4"}}},"description":"OK"}},"summary":"[STUB] Check if a user group name is available"}},"/user-groups/{gid}":{"delete":{"description":" [internal route ID: \"delete-user-group\"]\n\n","operationId":"delete-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User group deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}},"get":{"description":" [internal route ID: \"get-user-group\"]\n\n","operationId":"get-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"include_channels","schema":{"default":false,"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}}},"description":"User Group Found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or User group not found (label: `user-group-not-found`)\n\nUser group not found (label: `user-group-not-found`)"}},"summary":"Fetch a group accessible to the logged-in user"},"put":{"description":" [internal route ID: \"update-user-group\"]\n\n","operationId":"update-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupUpdate_MjUyNTA3Mjgy"}}},"required":true},"responses":{"200":{"description":"User added updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}}},"/user-groups/{gid}/channels":{"put":{"description":" [internal route ID: \"update-user-group-channels\"]\n\n","operationId":"update-user-group-channels","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"append_only","schema":{"default":false,"type":"boolean"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateUserGroupChannels_LTIyMjcwMTMx"}}},"required":true},"responses":{"200":{"description":"User group channels updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}},"summary":"Replaces the channels with the given list."}},"/user-groups/{gid}/users":{"post":{"description":" [internal route ID: \"add-users-to-group-bulk\"]\n\n","operationId":"add-users-to-group-bulk","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupAddUsers_LTgzOTYzNzk0"}}},"required":true},"responses":{"204":{"description":"Users added to group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nOnly team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}},"put":{"description":" [internal route ID: \"update-user-group-members\"]\n\n","operationId":"update-user-group-members","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateUserGroupMembers_LTg1MzQ2NDY3"}}},"required":true},"responses":{"200":{"description":"User group members updated"}},"summary":"[STUB] Update user group members. Replaces the users with the given list."}},"/user-groups/{gid}/users/{uid}":{"delete":{"description":" [internal route ID: \"remove-user-from-group\"]\n\n","operationId":"remove-user-from-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User removed from group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or `uid` not found\n\nUser group not found (label: `user-group-not-found`)"}}},"post":{"description":" [internal route ID: \"add-user-to-group\"]\n\n","operationId":"add-user-to-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User added to group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or `uid` not found\n\nUser group not found (label: `user-group-not-found`)"}}}},"/users/list-clients":{"post":{"description":" [internal route ID: \"list-clients-bulk@v2\"]\n\nIf a backend is unreachable, the clients from that backend will be omitted from the response","operationId":"list-clients-bulk@v2","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LimitedQualifiedUserIdList_500"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"qualified_user_map":{"$ref":"#/components/schemas/QualifiedUserMap_Set_PubClient"}},"type":"object"}}},"description":""}},"summary":"List all clients for a set of user ids"}},"/users/list-prekeys":{"post":{"description":" [internal route ID: \"get-multi-user-prekey-bundle-qualified\"]\n\nYou can't request information for more users than maximum conversation size.","operationId":"get-multi-user-prekey-bundle-qualified","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QualifiedUserClients"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QualifiedUserClientPrekeyMapV4_MzcxOTAxMTYy"}}},"description":""}},"summary":"(deprecated) Given a map of user IDs to client IDs return a prekey for each one."}},"/users/{uid_domain}/{uid}":{"get":{"description":" [internal route ID: \"get-user-qualified\"]\n\n","operationId":"get-user-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}}},"description":"User found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`uid_domain` or `uid` or User not found (label: `not-found`)"}},"summary":"Get a user by Domain and UserId"}},"/users/{uid_domain}/{uid}/clients/{client}":{"get":{"description":" [internal route ID: \"get-user-client-qualified\"]\n\nHint: to list all clients of one or more users, use POST /users/list-clients.","operationId":"get-user-client-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PubClient"}}},"description":""}},"summary":"Get a specific client of a user"}},"/users/{uid_domain}/{uid}/prekeys":{"get":{"description":" [internal route ID: \"get-users-prekey-bundle-qualified\"]\n\n","operationId":"get-users-prekey-bundle-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PrekeyBundle_MzgzOTk4MjYz"}}},"description":""}},"summary":"Get a prekey for each client of a user."}},"/users/{uid_domain}/{uid}/prekeys/{client}":{"get":{"description":" [internal route ID: \"get-users-prekeys-client-qualified\"]\n\n","operationId":"get-users-prekeys-client-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientPrekey_LTcyODUzMTcw"}}},"description":""}},"summary":"Get a prekey for a specific client of a user."}},"/users/{uid_domain}/{uid}/supported-protocols":{"get":{"description":" [internal route ID: \"get-supported-protocols\"]\n\n","operationId":"get-supported-protocols","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array","uniqueItems":true}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array","uniqueItems":true}}},"description":"Protocols supported by the user"}},"summary":"Get a user's supported protocols"}},"/users/{uid}/email":{"put":{"description":" [internal route ID: \"update-user-email\"]\n\nIf the user has a pending email validation, the validation email will be resent.","operationId":"update-user-email","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/EmailUpdate_NjQ5MDg1OTY0"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""}},"summary":"Resend email address validation email."}},"/users/{uid}/rich-info":{"get":{"description":" [internal route ID: \"get-rich-info\"]\n\n","operationId":"get-rich-info","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RichInfoAssocList"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RichInfoAssocList"}}},"description":"Rich info about the user"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}},"summary":"Get a user's rich info"}},"/users/{uid}/searchable":{"post":{"description":" [internal route ID: \"set-user-searchable\"]\n\n","operationId":"set-user-searchable","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SetSearchable_NDAxODAxODI5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""}},"summary":"Set user's visibility in search"}},"/verification-code/send":{"post":{"description":" [internal route ID: \"send-verification-code\"]\n\n","operationId":"send-verification-code","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SendVerificationCode_MjgxNDgxODE2"}}},"required":true},"responses":{"200":{"description":"Verification code sent."}},"summary":"Send a verification code to a given email address."}},"/websocket":{"get":{"description":" [internal route ID: \"websocket\"]\n\nThis is a temporary copy of await, please do not use it","externalDocs":{"description":"RFC 6455","url":"https://datatracker.ietf.org/doc/html/rfc6455"},"operationId":"websocket","parameters":[{"description":"Client ID","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"101":{"description":"Connection upgraded."},"426":{"description":"Upgrade required."}},"summary":"Establish websocket connection"}}},"security":[{"ZAuth":[]}],"servers":[{"url":"/v16"}]} From 2d0631a8dd7b4a1d7de6370822c3d58f3b2c8158 Mon Sep 17 00:00:00 2001 From: Valentin Date: Thu, 23 Jul 2026 10:48:42 +0200 Subject: [PATCH 027/113] Envoy: add annotations to httpRoutes and Ingress (#5358) --- .../WPB-27162-wire-ingress-external-dns-annotations | 6 ++++++ charts/nginx-ingress-services/templates/ingress.yaml | 7 ++++++- .../templates/ingress_federator.yaml | 3 +++ charts/nginx-ingress-services/templates/ingress_minio.yaml | 3 +++ charts/nginx-ingress-services/values.yaml | 3 +++ charts/wire-ingress/templates/httproute-account-pages.yaml | 4 ++++ charts/wire-ingress/templates/httproute-federator.yaml | 4 ++++ .../wire-ingress/templates/httproute-nginz-websockets.yaml | 4 ++++ charts/wire-ingress/templates/httproute-nginz.yaml | 4 ++++ charts/wire-ingress/templates/httproute-s3.yaml | 4 ++++ charts/wire-ingress/templates/httproute-team-settings.yaml | 4 ++++ charts/wire-ingress/templates/httproute-webapp.yaml | 4 ++++ charts/wire-ingress/templates/service-account-pages.yaml | 2 +- charts/wire-ingress/templates/service-team-settings.yaml | 2 +- charts/wire-ingress/templates/service-webapp.yaml | 2 +- charts/wire-ingress/values.yaml | 7 +++++++ 16 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 changelog.d/5-internal/WPB-27162-wire-ingress-external-dns-annotations diff --git a/changelog.d/5-internal/WPB-27162-wire-ingress-external-dns-annotations b/changelog.d/5-internal/WPB-27162-wire-ingress-external-dns-annotations new file mode 100644 index 00000000000..b84d4f81c4f --- /dev/null +++ b/changelog.d/5-internal/WPB-27162-wire-ingress-external-dns-annotations @@ -0,0 +1,6 @@ +The wire-ingress and nginx-ingress-services charts now expose annotation +passthroughs (`httpRoute.annotations` on the HTTPRoutes, `ingress.annotations` on +the Ingresses), so external-dns weighted records (set-identifier/aws-weight) can +be attached to both routers for a zero-downtime nginx-to-Envoy DNS cutover. +wire-ingress also gains a `service.create` toggle (default true) to reuse the +backend Services owned by nginx-ingress-services while both run in parallel. diff --git a/charts/nginx-ingress-services/templates/ingress.yaml b/charts/nginx-ingress-services/templates/ingress.yaml index 906d7f08dde..5e613c83d71 100644 --- a/charts/nginx-ingress-services/templates/ingress.yaml +++ b/charts/nginx-ingress-services/templates/ingress.yaml @@ -2,8 +2,12 @@ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "nginx-ingress-services.getIngressName" . | quote }} - {{- if .Values.config.renderCSPInIngress }} + {{- if or .Values.ingress.annotations .Values.config.renderCSPInIngress }} annotations: + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- if .Values.config.renderCSPInIngress }} {{- if not (hasPrefix "nginx" .Values.config.ingressClass) }} {{ fail "In ingress CSP header setting only works with a 'nginx' controller. (Rename it to 'nginx-*' if it is one.)" }} {{- end }} @@ -58,6 +62,7 @@ metadata: set $CSP "${CSP} upgrade-insecure-requests"; more_set_headers "content-security-policy: $CSP"; } + {{- end }} {{- end }} spec: ingressClassName: "{{ .Values.config.ingressClass }}" diff --git a/charts/nginx-ingress-services/templates/ingress_federator.yaml b/charts/nginx-ingress-services/templates/ingress_federator.yaml index 4602fe98112..f9018c3129b 100644 --- a/charts/nginx-ingress-services/templates/ingress_federator.yaml +++ b/charts/nginx-ingress-services/templates/ingress_federator.yaml @@ -9,6 +9,9 @@ kind: Ingress metadata: name: federator-ingress annotations: + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/backend-protocol: "HTTP" nginx.ingress.kubernetes.io/auth-tls-verify-client: "on" diff --git a/charts/nginx-ingress-services/templates/ingress_minio.yaml b/charts/nginx-ingress-services/templates/ingress_minio.yaml index fed523e9e99..f21871faf38 100644 --- a/charts/nginx-ingress-services/templates/ingress_minio.yaml +++ b/charts/nginx-ingress-services/templates/ingress_minio.yaml @@ -6,6 +6,9 @@ kind: Ingress metadata: name: {{ include "nginx-ingress-services.getMinioIngressName" . | quote }} annotations: + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} nginx.ingress.kubernetes.io/server-snippet: | location /minio/ { return 403; diff --git a/charts/nginx-ingress-services/values.yaml b/charts/nginx-ingress-services/values.yaml index e77de580fb9..c9c7f88f0ec 100644 --- a/charts/nginx-ingress-services/values.yaml +++ b/charts/nginx-ingress-services/values.yaml @@ -126,6 +126,9 @@ service: accountPages: externalPort: 8080 +ingress: + # Annotations added to the Ingress resources created by this chart. + annotations: {} config: ingressClass: "nginx" # You will need to supply some DNS names, namely diff --git a/charts/wire-ingress/templates/httproute-account-pages.yaml b/charts/wire-ingress/templates/httproute-account-pages.yaml index c3ef24a374b..8f4d12aa456 100644 --- a/charts/wire-ingress/templates/httproute-account-pages.yaml +++ b/charts/wire-ingress/templates/httproute-account-pages.yaml @@ -4,6 +4,10 @@ kind: HTTPRoute metadata: name: {{ include "wire-ingress.fullname" . }}-account-pages namespace: {{ .Release.Namespace }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" release: "{{ .Release.Name }}" diff --git a/charts/wire-ingress/templates/httproute-federator.yaml b/charts/wire-ingress/templates/httproute-federator.yaml index a747c27d8c2..792d5045ce9 100644 --- a/charts/wire-ingress/templates/httproute-federator.yaml +++ b/charts/wire-ingress/templates/httproute-federator.yaml @@ -4,6 +4,10 @@ kind: HTTPRoute metadata: name: {{ include "wire-ingress.fullname" . }}-federator namespace: {{ .Release.Namespace }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" release: "{{ .Release.Name }}" diff --git a/charts/wire-ingress/templates/httproute-nginz-websockets.yaml b/charts/wire-ingress/templates/httproute-nginz-websockets.yaml index 5d9881d98d9..a0c34d80b95 100644 --- a/charts/wire-ingress/templates/httproute-nginz-websockets.yaml +++ b/charts/wire-ingress/templates/httproute-nginz-websockets.yaml @@ -4,6 +4,10 @@ kind: HTTPRoute metadata: name: {{ include "wire-ingress.fullname" . }}-nginz-websockets namespace: {{ .Release.Namespace }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" release: "{{ .Release.Name }}" diff --git a/charts/wire-ingress/templates/httproute-nginz.yaml b/charts/wire-ingress/templates/httproute-nginz.yaml index 7bc50bb2890..907fa17722c 100644 --- a/charts/wire-ingress/templates/httproute-nginz.yaml +++ b/charts/wire-ingress/templates/httproute-nginz.yaml @@ -3,6 +3,10 @@ kind: HTTPRoute metadata: name: {{ include "wire-ingress.fullname" . }}-nginz namespace: {{ .Release.Namespace }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" release: "{{ .Release.Name }}" diff --git a/charts/wire-ingress/templates/httproute-s3.yaml b/charts/wire-ingress/templates/httproute-s3.yaml index afb68412d74..7568e1700f3 100644 --- a/charts/wire-ingress/templates/httproute-s3.yaml +++ b/charts/wire-ingress/templates/httproute-s3.yaml @@ -4,6 +4,10 @@ kind: HTTPRoute metadata: name: {{ include "wire-ingress.fullname" . }}-minio namespace: {{ .Release.Namespace }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" release: "{{ .Release.Name }}" diff --git a/charts/wire-ingress/templates/httproute-team-settings.yaml b/charts/wire-ingress/templates/httproute-team-settings.yaml index 2de4cc0ea49..e523788439f 100644 --- a/charts/wire-ingress/templates/httproute-team-settings.yaml +++ b/charts/wire-ingress/templates/httproute-team-settings.yaml @@ -4,6 +4,10 @@ kind: HTTPRoute metadata: name: {{ include "wire-ingress.fullname" . }}-team-settings namespace: {{ .Release.Namespace }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" release: "{{ .Release.Name }}" diff --git a/charts/wire-ingress/templates/httproute-webapp.yaml b/charts/wire-ingress/templates/httproute-webapp.yaml index 158836040d1..cc17a2d9404 100644 --- a/charts/wire-ingress/templates/httproute-webapp.yaml +++ b/charts/wire-ingress/templates/httproute-webapp.yaml @@ -4,6 +4,10 @@ kind: HTTPRoute metadata: name: {{ include "wire-ingress.fullname" . }}-webapp namespace: {{ .Release.Namespace }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" release: "{{ .Release.Name }}" diff --git a/charts/wire-ingress/templates/service-account-pages.yaml b/charts/wire-ingress/templates/service-account-pages.yaml index 7a77dbea0d6..f3d43252903 100644 --- a/charts/wire-ingress/templates/service-account-pages.yaml +++ b/charts/wire-ingress/templates/service-account-pages.yaml @@ -1,4 +1,4 @@ -{{- if .Values.accountPages.enabled }} +{{- if and .Values.service.create .Values.accountPages.enabled }} apiVersion: v1 kind: Service metadata: diff --git a/charts/wire-ingress/templates/service-team-settings.yaml b/charts/wire-ingress/templates/service-team-settings.yaml index 08b1fe6dfb1..6d2900b1cb6 100644 --- a/charts/wire-ingress/templates/service-team-settings.yaml +++ b/charts/wire-ingress/templates/service-team-settings.yaml @@ -1,4 +1,4 @@ -{{- if .Values.teamSettings.enabled }} +{{- if and .Values.service.create .Values.teamSettings.enabled }} apiVersion: v1 kind: Service metadata: diff --git a/charts/wire-ingress/templates/service-webapp.yaml b/charts/wire-ingress/templates/service-webapp.yaml index 7e2d0d496a4..efdccd279fc 100644 --- a/charts/wire-ingress/templates/service-webapp.yaml +++ b/charts/wire-ingress/templates/service-webapp.yaml @@ -1,4 +1,4 @@ -{{- if .Values.webapp.enabled }} +{{- if and .Values.service.create .Values.webapp.enabled }} apiVersion: v1 kind: Service metadata: diff --git a/charts/wire-ingress/values.yaml b/charts/wire-ingress/values.yaml index 0e6e901d266..756938a7130 100644 --- a/charts/wire-ingress/values.yaml +++ b/charts/wire-ingress/values.yaml @@ -1,4 +1,8 @@ # Default values for wire-ingress +httpRoute: + # Annotations added to every HTTPRoute created by this chart. + annotations: {} + gateway: # If true, a Gateway resource is created by this chart. # If false, set gateway.name to reference an existing Gateway. @@ -141,6 +145,9 @@ accountPages: enabled: false service: + # Set false to reuse the backend Services owned by nginx-ingress-services + # (e.g. during a migration) instead of creating them here. + create: true webapp: externalPort: 8080 teamSettings: From 8837aa0f958ba4cfc6301c6ee7fcb37842791ebe Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 23 Jul 2026 12:19:51 +0200 Subject: [PATCH 028/113] WPB-27017 reconcile adminless groups on feature toggle (#5357) --- changelog.d/2-features/WPB-27017 | 1 + hack/helm_vars/wire-server/values.yaml.gotmpl | 2 +- integration/test/Notifications.hs | 10 +- integration/test/Test/AdminlessGroups.hs | 258 +++++++++++++++--- integration/test/Testlib/Cannon.hs | 1 + .../src/Wire/API/Event/Conversation.hs | 99 +++++++ libs/wire-api/src/Wire/API/Jobs.hs | 31 ++- .../golden/Test/Wire/API/Golden/Manual.hs | 8 +- .../Wire/API/Golden/Manual/AdminlessJobs.hs | 9 + .../testObject_AdminlessSetupJob_1.json | 4 + .../testObject_AdminlessSetupJob_2.json | 5 + ...versationsJobPayload_AdminlessSetup_1.json | 7 + .../unit/Test/Wire/API/Roundtrip/Aeson.hs | 3 + .../src/Wire/ConversationSubsystem.hs | 4 + .../Wire/ConversationSubsystem/Interpreter.hs | 2 + .../src/Wire/ConversationSubsystem/Notify.hs | 26 +- .../src/Wire/ConversationSubsystem/Update.hs | 186 +++++++++---- libs/wire-subsystems/src/Wire/JobSubsystem.hs | 4 + .../src/Wire/JobSubsystem/Interpreter.hs | 67 +++++ .../src/Wire/JobSubsystem/Migrations.hs | 43 ++- .../ConversationSubsystem/InterpreterSpec.hs | 4 + .../src/Wire/AdminlessJobsWorker.hs | 26 +- .../src/Wire/BackgroundWorker/Workers.hs | 4 +- .../galley/src/Galley/API/Teams/Features.hs | 54 +++- 24 files changed, 745 insertions(+), 113 deletions(-) create mode 100644 changelog.d/2-features/WPB-27017 create mode 100644 libs/wire-api/test/golden/testObject_AdminlessSetupJob_1.json create mode 100644 libs/wire-api/test/golden/testObject_AdminlessSetupJob_2.json create mode 100644 libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessSetup_1.json diff --git a/changelog.d/2-features/WPB-27017 b/changelog.d/2-features/WPB-27017 new file mode 100644 index 00000000000..fdf3d808f61 --- /dev/null +++ b/changelog.d/2-features/WPB-27017 @@ -0,0 +1 @@ +Add adminless-group reconciliation, teardown, and system events for member updates, reminders, and deletion. diff --git a/hack/helm_vars/wire-server/values.yaml.gotmpl b/hack/helm_vars/wire-server/values.yaml.gotmpl index 16863feea91..e7eac919d88 100644 --- a/hack/helm_vars/wire-server/values.yaml.gotmpl +++ b/hack/helm_vars/wire-server/values.yaml.gotmpl @@ -649,7 +649,7 @@ background-worker: jobTimeout: 60s maxAttempts: 3 jobs: - pollInterval: 5s + pollInterval: 1s # Poll every second so due jobs are discovered promptly in tests workerThreads: 1 visibilityTimeout: 60s jobHeartbeatInterval: 30s diff --git a/integration/test/Notifications.hs b/integration/test/Notifications.hs index f443cb621bc..b04d8f385b6 100644 --- a/integration/test/Notifications.hs +++ b/integration/test/Notifications.hs @@ -173,6 +173,9 @@ isConvNameChangeNotif n = fieldEquals n "payload.0.type" "conversation.rename" isMemberUpdateNotif :: (HasCallStack, MakesValue n) => n -> App Bool isMemberUpdateNotif n = fieldEquals n "payload.0.type" "conversation.member-update" +isConvSystemMemberUpdateNotif :: (HasCallStack, MakesValue n) => n -> App Bool +isConvSystemMemberUpdateNotif n = fieldEquals n "payload.0.type" "conversation.system.member-update" + isReceiptModeUpdateNotif :: (HasCallStack, MakesValue n) => n -> App Bool isReceiptModeUpdateNotif n = fieldEquals n "payload.0.type" "conversation.receipt-mode-update" @@ -215,11 +218,16 @@ isConvCreateNotifNotSelf n = &&~ do not <$> fieldEquals n "payload.0.data.access" ["private"] isConvDeleteNotif :: (HasCallStack, MakesValue a) => a -> App Bool -isConvDeleteNotif n = fieldEquals n "payload.0.type" "conversation.delete" +isConvDeleteNotif n = + fieldEquals n "payload.0.type" "conversation.delete" + ||~ fieldEquals n "payload.0.type" "conversation.system.delete" isConvAdminlessReminderNotif :: (HasCallStack, MakesValue a) => a -> App Bool isConvAdminlessReminderNotif n = fieldEquals n "payload.0.type" "conversation.adminless-reminder" +isConvSystemAdminlessReminderNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isConvSystemAdminlessReminderNotif n = fieldEquals n "payload.0.type" "conversation.system.adminless-reminder" + notifTypeIsEqual :: (HasCallStack, MakesValue a) => String -> a -> App Bool notifTypeIsEqual typ n = nPayload n %. "type" `isEqual` typ diff --git a/integration/test/Test/AdminlessGroups.hs b/integration/test/Test/AdminlessGroups.hs index f619d27679a..c6ba1667978 100644 --- a/integration/test/Test/AdminlessGroups.hs +++ b/integration/test/Test/AdminlessGroups.hs @@ -31,7 +31,7 @@ testOnLastAdminLeaveReturnEligibleMembers = do -- bob is eligible (alice, tid, [bob]) <- createTeam OwnDomain 2 - setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked" + setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked" patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "enabled"]) >>= assertSuccess -- local user is eligible @@ -46,8 +46,7 @@ testOnLastAdminLeaveReturnEligibleMembers = do connectTwoUsers alice remoteUser -- app is not eligible - let newApp :: NewApp - newApp = def {name = "some-app", description = "non-eligible app member"} + let newApp = def {name = "some-app", description = "non-eligible app member"} app <- bindResponse (createApp alice tid newApp) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "user" @@ -55,10 +54,8 @@ testOnLastAdminLeaveReturnEligibleMembers = do clients@(alice1 : tmpUser1 : _) <- traverse (createMLSClient def) [alice, tmpUser, bob, localUser, remoteUser, app] for_ clients (uploadNewKeyPackage def) - conv <- postConversation alice (allowAll defMLS) {team = Just tid} >>= getJSON 201 + conv <- createTeamMLSConversation alice tid alice1 [bob, app, localUser, remoteUser] convId <- objConvId conv - createGroup def alice1 convId - void $ createAddCommit alice1 convId [bob, app, localUser, remoteUser] >>= sendAndConsumeCommitBundle (key, code) <- bindResponse (postConversationCode alice conv Nothing Nothing) $ \resp -> do res <- getJSON 201 resp @@ -116,43 +113,18 @@ testOnLastAdminLeaveReturnEligibleMembers = do testOnLastAdminLeaveNoEligibleMembersExist :: (HasCallStack) => App () testOnLastAdminLeaveNoEligibleMembersExist = do (alice, tid, _) <- createTeam OwnDomain 1 - - setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked" - patchTeamFeature - OwnDomain - tid - "preventAdminlessGroups" - ( object - [ "status" .= "enabled", - "config" - .= object - -- The reminders are due early (+1s and +2s), while deletion is - -- later (+10s). This gives Arbiter's 1s polling and serial - -- grouped-job processing enough room to emit both reminders - -- before the conversation is deleted. - [ "deletionTimeoutDuration" .= "10s", - "reminderTimeoutDurations" .= ["9s", "8s"], - "promotionStrategy" .= "random" - ] - ] - ) - >>= assertSuccess - - let newApp :: NewApp - newApp = def {name = "adminless-reminder-app", description = "not eligible for promotion"} - app <- bindResponse (createApp alice tid newApp) $ \resp -> do - resp.status `shouldMatchInt` 200 - resp.json %. "user" + configureAdminlessGroupsFeature OwnDomain tid "enabled" "10s" ["9s", "8s"] tmpUser <- ephemeralUser OwnDomain - clients@(alice1 : tmpUser1 : _) <- traverse (createMLSClient def) [alice, tmpUser, app] - traverse_ (uploadNewKeyPackage def) clients + alice1 <- createMLSClient def alice + tmpUser1 <- createMLSClient def tmpUser + traverse_ (uploadNewKeyPackage def) [alice1, tmpUser1] - conv <- postConversation alice (allowAll defMLS) {team = Just tid} >>= getJSON 201 + conv <- createTeamMLSConversation alice tid alice1 [] + let newApp = def {name = "adminless-reminder-app", description = "not eligible for promotion"} + (app, _) <- createAndAddAppMember alice tid alice1 conv newApp convId <- objConvId conv - createGroup def alice1 convId - void $ createAddCommit alice1 convId [app] >>= sendAndConsumeCommitBundle (key, code) <- bindResponse (postConversationCode alice conv Nothing Nothing) $ \resp -> do res <- getJSON 201 resp @@ -182,12 +154,174 @@ testOnLastAdminLeaveNoEligibleMembersExist = do bindResponse (GalleyI.getConversation conv) $ \resp -> do resp.status `shouldMatchInt` 404 +testAdminlessSetupOnFeatureEnable :: (HasCallStack) => App () +testAdminlessSetupOnFeatureEnable = do + (alice, tid, _) <- createTeam OwnDomain 1 + + setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked" + patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "disabled"]) >>= assertSuccess + + alice1 <- createMLSClient def alice + void $ uploadNewKeyPackage def alice1 + conv <- createTeamMLSConversation alice tid alice1 [] + let newApp = def {name = "adminless-setup-app", description = "not eligible for promotion"} + (app, _) <- createAndAddAppMember alice tid alice1 conv newApp + + -- The feature is disabled, so leaving the conversation must not schedule a + -- deletion job. Enabling it afterwards exercises the team reconciliation job. + bindResponse (removeMember alice conv alice) $ \resp -> do + resp.status `shouldMatchInt` 200 + bindResponse (GalleyI.getConversation conv) $ \resp -> do + resp.status `shouldMatchInt` 200 + + withWebSockets [app] $ \[wsApp] -> do + configureAdminlessGroupsFeature OwnDomain tid "enabled" "5s" ["4s"] + + -- Leave enough margin for the setup job to enqueue both jobs and for them + -- to be picked up when the integration suite is under load. + reminder <- awaitMatchFor 20 isConvSystemAdminlessReminderNotif wsApp + reminder %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + void $ reminder %. "payload.0.data.deletion_scheduled_for" & asString + void $ awaitMatchFor 20 isConvDeleteNotif wsApp + bindResponse (GalleyI.getConversation conv) $ \resp -> do + resp.status `shouldMatchInt` 404 + +testAdminlessSetupSystemMemberUpdate :: (HasCallStack) => App () +testAdminlessSetupSystemMemberUpdate = do + (alice, tid, [bob]) <- createTeam OwnDomain 2 + + setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked" + patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "disabled"]) >>= assertSuccess + + [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] + traverse_ (uploadNewKeyPackage def) [alice1, bob1] + + conv <- createTeamMLSConversation alice tid alice1 [bob] + + -- Create an adminless conversation while the feature is disabled. The setup + -- job will later autopromote bob without an originating user ID. + bindResponse (removeMember alice conv alice) $ \resp -> do + resp.status `shouldMatchInt` 200 + + withWebSockets [bob] $ \[wsBob] -> do + patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "enabled"]) >>= assertSuccess + + notif <- awaitMatchFor 20 isConvSystemMemberUpdateNotif wsBob + notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + notif %. "payload.0.data.qualified_target" `shouldMatch` objQidObject bob + notif %. "payload.0.data.conversation_role" `shouldMatch` "wire_admin" + + bindResponse (getConversation bob conv) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin" + +testAdminlessJobsCancelledOnFeatureDisable :: (HasCallStack) => App () +testAdminlessJobsCancelledOnFeatureDisable = do + (alice, tid, _) <- createTeam OwnDomain 1 + configureAdminlessGroupsFeature OwnDomain tid "enabled" "5s" [] + + alice1 <- createMLSClient def alice + void $ uploadNewKeyPackage def alice1 + conv <- createTeamMLSConversation alice tid alice1 [] + let newApp = def {name = "adminless-cancel-app", description = "not eligible for promotion"} + (app, _) <- createAndAddAppMember alice tid alice1 conv newApp + + withWebSockets [app] $ \[wsApp] -> do + -- Leaving schedules deletion while the feature is enabled. + bindResponse (removeMember alice conv alice) $ \resp -> do + resp.status `shouldMatchInt` 200 + + -- Disabling the feature must cancel the pending deletion before its deadline. + patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "disabled"]) >>= assertSuccess + + -- Wait beyond the original deadline and worker polling window. The + -- conversation must remain because the pending job was canceled. Feature + -- update events are ignored by the matcher. + result <- awaitNMatchesResultFor 15 1 isConvDeleteNotif wsApp + result.success `shouldMatch` False + bindResponse (GalleyI.getConversation conv) $ \resp -> do + resp.status `shouldMatchInt` 200 + +testAdminlessJobsRecreatedOnFeatureConfigChange :: (HasCallStack) => App () +testAdminlessJobsRecreatedOnFeatureConfigChange = do + (alice, tid, _) <- createTeam OwnDomain 1 + + configureAdminlessGroupsFeature OwnDomain tid "enabled" "10s" [] + + alice1 <- createMLSClient def alice + void $ uploadNewKeyPackage def alice1 + conv <- createTeamMLSConversation alice tid alice1 [] + let newApp = def {name = "adminless-reschedule-app", description = "not eligible for promotion"} + (app, _) <- createAndAddAppMember alice tid alice1 conv newApp + + withWebSockets [app] $ \[wsApp] -> do + -- Leaving schedules a deletion using the original timeout. + bindResponse (removeMember alice conv alice) $ \resp -> do + resp.status `shouldMatchInt` 200 + + -- Changing the configuration must cancel the old job and recreate it + -- using the new timeout. + configureAdminlessGroupsFeature OwnDomain tid "enabled" "20s" [] + + -- If the old job was not canceled, it would delete the conversation after + -- 10s. Wait past that deadline before checking that it still exists. + oldJobResult <- awaitNMatchesResultFor 15 1 isConvDeleteNotif wsApp + oldJobResult.success `shouldMatch` False + bindResponse (GalleyI.getConversation conv) $ \resp -> do + resp.status `shouldMatchInt` 200 + + -- The replacement job uses the new 20s timeout. + void $ awaitMatchFor 30 isConvDeleteNotif wsApp + bindResponse (GalleyI.getConversation conv) $ \resp -> do + resp.status `shouldMatchInt` 404 + +testAdminlessJobCancellationIsTeamScoped :: (HasCallStack) => App () +testAdminlessJobCancellationIsTeamScoped = do + (alice, canceledTid, _) <- createTeam OwnDomain 1 + (bob, activeTid, _) <- createTeam OwnDomain 1 + + let enabledFeature = mkAdminlessFeature "enabled" "10s" [] + + for_ [canceledTid, activeTid] $ \tid -> do + setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked" + patchTeamFeature OwnDomain tid "preventAdminlessGroups" enabledFeature >>= assertSuccess + + let newApp name = def {name = name, description = "not eligible for promotion"} + alice1 <- createMLSClient def alice + bob1 <- createMLSClient def bob + traverse_ (uploadNewKeyPackage def) [alice1, bob1] + + canceledConv <- createTeamMLSConversation alice canceledTid alice1 [] + (canceledApp, _) <- createAndAddAppMember alice canceledTid alice1 canceledConv (newApp "adminless-cancel-team-app") + + activeConv <- createTeamMLSConversation bob activeTid bob1 [] + (activeApp, _) <- createAndAddAppMember bob activeTid bob1 activeConv (newApp "adminless-active-team-app") + + withWebSockets [canceledApp, activeApp] $ \[wsCanceled, wsActive] -> do + -- Schedule one deletion job for each team before disabling only one team. + bindResponse (removeMember alice canceledConv alice) $ \resp -> do + resp.status `shouldMatchInt` 200 + bindResponse (removeMember bob activeConv bob) $ \resp -> do + resp.status `shouldMatchInt` 200 + + patchTeamFeature OwnDomain canceledTid "preventAdminlessGroups" (object ["status" .= "disabled"]) >>= assertSuccess + + -- The cancellation query must not affect the other team's job. + canceledResult <- awaitNMatchesResultFor 20 1 isConvDeleteNotif wsCanceled + canceledResult.success `shouldMatch` False + void $ awaitMatchFor 30 isConvDeleteNotif wsActive + + bindResponse (GalleyI.getConversation canceledConv) $ \resp -> do + resp.status `shouldMatchInt` 200 + bindResponse (GalleyI.getConversation activeConv) $ \resp -> do + resp.status `shouldMatchInt` 404 + testOnLastAdminLeaveFeatureDisabled :: (HasCallStack) => App () testOnLastAdminLeaveFeatureDisabled = do -- bob is eligible (alice, tid, [bob]) <- createTeam OwnDomain 2 - setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked" + setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked" patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "disabled"]) >>= assertSuccess clients@(alice1 : _) <- traverse (createMLSClient def) [alice, bob] @@ -206,7 +340,7 @@ testOnLastAdminTeamMemberDeletionAutopromotes :: (HasCallStack) => App () testOnLastAdminTeamMemberDeletionAutopromotes = do (alice, tid, [charlie]) <- createTeam OwnDomain 2 - setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked" + setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked" patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "enabled"]) >>= assertSuccess [alice1, charlie1] <- traverse (createMLSClient def) [alice, charlie] @@ -239,7 +373,7 @@ testOnLastAdminSelfDeletionAutopromotes :: (HasCallStack) => App () testOnLastAdminSelfDeletionAutopromotes = do (alice, tid, [charlie]) <- createTeam OwnDomain 2 - setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked" + setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked" patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "enabled"]) >>= assertSuccess [alice1, charlie1] <- traverse (createMLSClient def) [alice, charlie] @@ -266,3 +400,45 @@ testOnLastAdminSelfDeletionAutopromotes = do resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin" members <- resp.json %. "members.others" & asList shouldBeEmpty members + +----------------------------------------------------------------------------------------------------------------------------- +-- UTILS + +createTeamMLSConversation :: (HasCallStack, MakesValue owner) => owner -> String -> ClientIdentity -> [Value] -> App Value +createTeamMLSConversation owner tid ownerClient members = do + conv <- postConversation owner (allowAll defMLS) {team = Just tid} >>= getJSON 201 + convId <- objConvId conv + createGroup def ownerClient convId + unless (null members) + $ void + $ createAddCommit ownerClient convId members + >>= sendAndConsumeCommitBundle + pure conv + +createAndAddAppMember :: (HasCallStack, MakesValue creator, MakesValue conv) => creator -> String -> ClientIdentity -> conv -> NewApp -> App (Value, ClientIdentity) +createAndAddAppMember creator tid ownerClient conv newApp = do + app <- bindResponse (createApp creator tid newApp) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "user" + appClient <- createMLSClient def app + void $ uploadNewKeyPackage def appClient + convId <- objConvId conv + void $ createAddCommit ownerClient convId [app] >>= sendAndConsumeCommitBundle + pure (app, appClient) + +configureAdminlessGroupsFeature :: (MakesValue domain) => domain -> String -> String -> String -> [String] -> App () +configureAdminlessGroupsFeature domain tid status deletionTimeout reminderTimeouts = do + setTeamFeatureLockStatus domain tid "preventAdminlessGroups" "unlocked" + patchTeamFeature domain tid "preventAdminlessGroups" (mkAdminlessFeature status deletionTimeout reminderTimeouts) >>= assertSuccess + +mkAdminlessFeature :: String -> String -> [String] -> Value +mkAdminlessFeature status deletionTimeout reminderTimeouts = + object + [ "status" .= status, + "config" + .= object + [ "deletionTimeoutDuration" .= deletionTimeout, + "reminderTimeoutDurations" .= reminderTimeouts, + "promotionStrategy" .= "random" + ] + ] diff --git a/integration/test/Testlib/Cannon.hs b/integration/test/Testlib/Cannon.hs index 9a8cdb6179f..4e7371a154a 100644 --- a/integration/test/Testlib/Cannon.hs +++ b/integration/test/Testlib/Cannon.hs @@ -26,6 +26,7 @@ module Testlib.Cannon withWebSocket, withWebSockets, awaitNMatchesResult, + awaitNMatchesResultFor, awaitNMatches, awaitMatch, awaitMatchFor, diff --git a/libs/wire-api/src/Wire/API/Event/Conversation.hs b/libs/wire-api/src/Wire/API/Event/Conversation.hs index f954da761f2..f6b28a24d43 100644 --- a/libs/wire-api/src/Wire/API/Event/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Event/Conversation.hs @@ -33,6 +33,10 @@ module Wire.API.Event.Conversation AddCodeResult (..), createConversationEventData, isCellsConversationEvent, + SystemEvent (..), + SystemEventType (..), + SystemEventData (..), + systemEventDataType, -- * Cells Event CellsEvent (..), @@ -60,6 +64,9 @@ module Wire.API.Event.Conversation _EdMLSMessage, _EdMLSWelcome, _EdAddPermissionUpdate, + _EdSystemConvDelete, + _EdSystemMemberUpdate, + _EdSystemAdminlessReminder, -- * Event data helpers SimpleMember (..), @@ -515,6 +522,98 @@ instance ToSchema AdminlessReminder where AdminlessReminder <$> (.deletionScheduledFor) .= field "deletion_scheduled_for" schema +data SystemEvent = SystemEvent + { seConv :: Qualified ConvId, + seSubConv :: Maybe SubConvId, + seTime :: UTCTime, + seTeam :: Maybe TeamId, + seData :: SystemEventData + } + deriving stock (Eq, Show, Generic) + +instance Arbitrary SystemEvent where + arbitrary = do + SystemEvent + <$> arbitrary + <*> arbitrary + <*> (milli <$> arbitrary) + <*> arbitrary + <*> arbitrary + where + milli = fromUTCTimeMillis . toUTCTimeMillis + +data SystemEventType + = SystemConvDelete + | SystemMemberUpdate + | SystemAdminlessReminder + deriving stock (Eq, Show, Generic, Enum, Bounded, Ord) + deriving (Arbitrary) via (GenericUniform SystemEventType) + deriving (FromJSON, ToJSON, S.ToSchema) via Schema SystemEventType + +instance ToSchema SystemEventType where + schema = + enum @Text $ + mconcat + [ element "conversation.system.delete" SystemConvDelete, + element "conversation.system.member-update" SystemMemberUpdate, + element "conversation.system.adminless-reminder" SystemAdminlessReminder + ] + +data SystemEventData + = EdSystemConvDelete + | EdSystemMemberUpdate MemberUpdateData + | EdSystemAdminlessReminder AdminlessReminder + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform SystemEventData) + +systemEventDataType :: SystemEventData -> SystemEventType +systemEventDataType EdSystemConvDelete = SystemConvDelete +systemEventDataType (EdSystemMemberUpdate _) = SystemMemberUpdate +systemEventDataType (EdSystemAdminlessReminder _) = SystemAdminlessReminder + +makePrisms ''SystemEventData + +taggedSystemEventDataSchema :: ObjectSchema SwaggerDoc (SystemEventType, SystemEventData) +taggedSystemEventDataSchema = + bind + (fst .= field "type" schema) + (snd .= fieldOver _1 "data" edata) + where + edata :: SchemaP SwaggerDoc (A.Value, SystemEventType) A.Value SystemEventData SystemEventData + edata = dispatch $ \case + SystemConvDelete -> tag _EdSystemConvDelete null_ + SystemMemberUpdate -> tag _EdSystemMemberUpdate (unnamed schema) + SystemAdminlessReminder -> tag _EdSystemAdminlessReminder (unnamed schema) + +instance ToSchema SystemEvent where + schema = object systemEventObjectSchema + +instance S.ToSchema SystemEvent where + declareNamedSchema = schemaToSwagger + +instance ToJSONObject SystemEvent where + toJSONObject = + KeyMap.fromList + . fromMaybe [] + . schemaOut systemEventObjectSchema + +systemEventObjectSchema :: ObjectSchema SwaggerDoc SystemEvent +systemEventObjectSchema = + mk + <$> (systemEventDataType . seData &&& seData) .= taggedSystemEventDataSchema + <* (qUnqualified . seConv) .= optional (field "conversation" schema) + <*> seConv .= field "qualified_conversation" schema + <*> seSubConv .= maybe_ (optField "subconv" schema) + <* const ("system" :: Text) .= field "via" (schema @Text) + <*> (toUTCTimeMillis . seTime) .= field "time" (fromUTCTimeMillis <$> schema) + <*> seTeam .= maybe_ (optField "team" schema) + where + mk (_, d) conv subconv tm team = SystemEvent conv subconv tm team d + +deriving via (Schema SystemEvent) instance FromJSON SystemEvent + +deriving via (Schema SystemEvent) instance ToJSON SystemEvent + makePrisms ''EventData taggedEventDataSchema :: ObjectSchema SwaggerDoc (EventType, EventData) diff --git a/libs/wire-api/src/Wire/API/Jobs.hs b/libs/wire-api/src/Wire/API/Jobs.hs index 58e47c92ff8..218e097f8ad 100644 --- a/libs/wire-api/src/Wire/API/Jobs.hs +++ b/libs/wire-api/src/Wire/API/Jobs.hs @@ -114,6 +114,26 @@ instance ToSchema AdminlessReminderJob where <*> (.adminlessReminderJobDeletionScheduledFor) .= field "deletion_scheduled_for" schema <*> (.adminlessReminderJobRequestId) .= field "request_id" schema +-- | Payload for reconciling all adminless groups in a team. +data AdminlessSetupJob = AdminlessSetupJob + { adminlessSetupJobTeamId :: TeamId, + adminlessSetupJobOrigUserId :: Maybe UserId, + adminlessSetupJobRequestId :: RequestId + } + deriving stock (Eq, Generic, Show) + deriving (ToJSON, FromJSON, S.ToSchema) via (Schema AdminlessSetupJob) + +instance Arbitrary AdminlessSetupJob where + arbitrary = AdminlessSetupJob <$> arbitrary <*> arbitrary <*> arbitrary + +instance ToSchema AdminlessSetupJob where + schema = + object $ + AdminlessSetupJob + <$> (.adminlessSetupJobTeamId) .= field "team_id" schema + <*> (.adminlessSetupJobOrigUserId) .= maybe_ (optField "orig_user_id" schema) + <*> (.adminlessSetupJobRequestId) .= field "request_id" schema + -- | Common representation for all queue payload envelopes. -- The queue-specific sum supplies the type tag and its associated data schema, -- while this helper guarantees the stable {"type": ..., "data": ...} shape. @@ -175,12 +195,14 @@ instance Arbitrary MeetingsJobPayload where -- | Payload persisted in the conversations queue. Keep the type tags and -- nested data shapes stable when changing job payloads. data ConversationsJobPayload - = AdminlessDeletion AdminlessDeletionJob + = AdminlessSetup AdminlessSetupJob + | AdminlessDeletion AdminlessDeletionJob | AdminlessReminder AdminlessReminderJob deriving stock (Eq, Generic, Show) data ConversationsJobPayloadTag - = AdminlessReminderTag + = AdminlessSetupTag + | AdminlessReminderTag | AdminlessDeletionTag deriving stock (Eq, Ord, Bounded, Enum, Show, Generic) deriving (Arbitrary) via GenericUniform ConversationsJobPayloadTag @@ -189,7 +211,8 @@ instance ToSchema ConversationsJobPayloadTag where schema = enum @Text $ mconcat - [ element "adminless_deletion" AdminlessDeletionTag, + [ element "adminless_setup" AdminlessSetupTag, + element "adminless_deletion" AdminlessDeletionTag, element "adminless_reminder" AdminlessReminderTag ] @@ -201,11 +224,13 @@ conversationsJobPayloadObjectSchema = taggedJobPayloadObjectSchema toTag toSchem toTag :: ConversationsJobPayload -> ConversationsJobPayloadTag toTag = \case + AdminlessSetup {} -> AdminlessSetupTag AdminlessDeletion {} -> AdminlessDeletionTag AdminlessReminder {} -> AdminlessReminderTag toSchema :: ConversationsJobPayloadTag -> ObjectSchema SwaggerDoc ConversationsJobPayload toSchema = \case + AdminlessSetupTag -> tag _AdminlessSetup (field "data" schema) AdminlessDeletionTag -> tag _AdminlessDeletion (field "data" schema) AdminlessReminderTag -> tag _AdminlessReminder (field "data" schema) diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs index c726b3f2e25..fe5e8627f57 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs @@ -85,6 +85,11 @@ tests = [ (testObject_AdminlessReminderJob_1, "testObject_AdminlessReminderJob_1.json"), (testObject_AdminlessReminderJob_2, "testObject_AdminlessReminderJob_2.json") ], + testGroup "AdminlessSetupJob" $ + testObjects + [ (testObject_AdminlessSetupJob_1, "testObject_AdminlessSetupJob_1.json"), + (testObject_AdminlessSetupJob_2, "testObject_AdminlessSetupJob_2.json") + ], testGroup "MeetingsJobPayload" $ testObjects [ (testObject_MeetingsJobPayload_MeetingsCleanup_1, "testObject_MeetingsJobPayload_MeetingsCleanup_1.json") @@ -92,7 +97,8 @@ tests = testGroup "ConversationsJobPayload" $ testObjects [ (testObject_ConversationsJobPayload_AdminlessDeletion_1, "testObject_ConversationsJobPayload_AdminlessDeletion_1.json"), - (testObject_ConversationsJobPayload_AdminlessReminder_1, "testObject_ConversationsJobPayload_AdminlessReminder_1.json") + (testObject_ConversationsJobPayload_AdminlessReminder_1, "testObject_ConversationsJobPayload_AdminlessReminder_1.json"), + (testObject_ConversationsJobPayload_AdminlessSetup_1, "testObject_ConversationsJobPayload_AdminlessSetup_1.json") ], testGroup "CreatedApp" $ testObjects [(testObject_CreatedApp_1, "testObject_CreatedApp_1.json")], diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/AdminlessJobs.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/AdminlessJobs.hs index a85dde3df35..2a498dd8297 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/AdminlessJobs.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/AdminlessJobs.hs @@ -50,6 +50,12 @@ testObject_AdminlessReminderJob_1 = AdminlessReminderJob teamId conversationId N testObject_AdminlessReminderJob_2 :: AdminlessReminderJob testObject_AdminlessReminderJob_2 = AdminlessReminderJob teamId conversationId (Just originUserId) deletionScheduledFor requestId +testObject_AdminlessSetupJob_1 :: AdminlessSetupJob +testObject_AdminlessSetupJob_1 = AdminlessSetupJob teamId Nothing requestId + +testObject_AdminlessSetupJob_2 :: AdminlessSetupJob +testObject_AdminlessSetupJob_2 = AdminlessSetupJob teamId (Just originUserId) requestId + testObject_MeetingsJobPayload_MeetingsCleanup_1 :: MeetingsJobPayload testObject_MeetingsJobPayload_MeetingsCleanup_1 = MeetingsCleanup MeetingsCleanupJob @@ -58,3 +64,6 @@ testObject_ConversationsJobPayload_AdminlessDeletion_1 = AdminlessDeletion testO testObject_ConversationsJobPayload_AdminlessReminder_1 :: ConversationsJobPayload testObject_ConversationsJobPayload_AdminlessReminder_1 = AdminlessReminder testObject_AdminlessReminderJob_1 + +testObject_ConversationsJobPayload_AdminlessSetup_1 :: ConversationsJobPayload +testObject_ConversationsJobPayload_AdminlessSetup_1 = AdminlessSetup testObject_AdminlessSetupJob_1 diff --git a/libs/wire-api/test/golden/testObject_AdminlessSetupJob_1.json b/libs/wire-api/test/golden/testObject_AdminlessSetupJob_1.json new file mode 100644 index 00000000000..e9b075e3423 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_AdminlessSetupJob_1.json @@ -0,0 +1,4 @@ +{ + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" +} diff --git a/libs/wire-api/test/golden/testObject_AdminlessSetupJob_2.json b/libs/wire-api/test/golden/testObject_AdminlessSetupJob_2.json new file mode 100644 index 00000000000..75e75341061 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_AdminlessSetupJob_2.json @@ -0,0 +1,5 @@ +{ + "orig_user_id": "00000000-0000-0000-0000-000000000003", + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" +} diff --git a/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessSetup_1.json b/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessSetup_1.json new file mode 100644 index 00000000000..0f074f2d34b --- /dev/null +++ b/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessSetup_1.json @@ -0,0 +1,7 @@ +{ + "data": { + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" + }, + "type": "adminless_setup" +} diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs index 9f3b207bbf0..f79bd4c229b 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs @@ -160,11 +160,14 @@ tests = testRoundTrip @Jobs.MeetingsCleanupJob, testRoundTripWithSwagger @Jobs.AdminlessDeletionJob, testRoundTripWithSwagger @Jobs.AdminlessReminderJob, + testRoundTripWithSwagger @Jobs.AdminlessSetupJob, testRoundTrip @Jobs.MeetingsJobPayload, testRoundTrip @Jobs.ConversationsJobPayload, testRoundTrip @EJPD.EJPDContact, testRoundTrip @Event.Conversation.Event, testRoundTrip @Event.Conversation.EventType, + testRoundTrip @Event.Conversation.SystemEvent, + testRoundTrip @Event.Conversation.SystemEventType, testRoundTrip @Event.Conversation.SimpleMember, testRoundTrip @Event.Conversation.MembersJoin, testRoundTrip @Event.Conversation.Connect, diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs index d6beb8c33f3..b9b24d5274e 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs @@ -351,6 +351,10 @@ data ConversationSubsystem m a where Local ConvId -> UTCTimeMillis -> ConversationSubsystem m () + SetupAdminlessGroupsCleanup :: + Maybe (Local UserId) -> + TeamId -> + ConversationSubsystem m () GetMLSPublicKeys :: Maybe MLSPublicKeyFormat -> ConversationSubsystem m (MLSKeysByPurpose (MLSKeys SomeKey)) diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs index 884e707d313..69603274250 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs @@ -217,6 +217,8 @@ interpretConversationSubsystem = interpret $ \case mapErrors $ Update.adminlessAutopromoteOrDelete lusr lcnv InternalNotifyAdminlessReminder lusr lcnv deletionScheduledFor -> mapErrors $ Update.adminlessAutopromoteOrSendReminder lusr lcnv deletionScheduledFor + SetupAdminlessGroupsCleanup lusr tid -> + mapErrors $ Update.setupAdminlessGroupsCleanup lusr tid GetMLSPublicKeys fmt -> mapErrors $ MLS.getMLSPublicKeys fmt ResetMLSConversation lusr reset -> diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Notify.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Notify.hs index a02de14f9aa..98130ac5571 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Notify.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Notify.hs @@ -15,9 +15,15 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.ConversationSubsystem.Notify (notifyConversationActionImpl) where +module Wire.ConversationSubsystem.Notify + ( notifyConversationActionImpl, + pushSystemEvent, + ) +where +import Data.Default import Data.Id +import Data.Json.Util (ToJSONObject (toJSONObject)) import Data.Qualified import Data.Singletons (Sing) import Imports @@ -89,3 +95,21 @@ notifyConversationActionImpl tag eventFrom notifyOrigDomain con lconv targetsLoc pushConversationEvent con conv.metadata.cnvmCellsState e (qualifyAs lcnv targetsLocal) targetsBots pure $ LocalConversationUpdate {lcuEvent = e, lcuUpdate = update} + +pushSystemEvent :: + (Member NotificationSubsystem r) => + Maybe ConnId -> + SystemEvent -> + Set UserId -> + Sem r () +pushSystemEvent con event targets = do + let eventJson = toJSONObject event + pushNotifications + [ def + { conn = con, + origin = Nothing, + json = eventJson, + recipients = map userRecipient (toList targets), + isCellsEvent = True + } + ] diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs index 599892d5bad..a5a7c673ef0 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs @@ -44,6 +44,7 @@ module Wire.ConversationSubsystem.Update updateCellsState, adminlessAutopromoteOrDelete, adminlessAutopromoteOrSendReminder, + setupAdminlessGroupsCleanup, -- * Managing Members addQualifiedMembersUnqualified, @@ -135,6 +136,7 @@ import Wire.ConversationSubsystem.Action import Wire.ConversationSubsystem.Action.Kick (kickMember) import Wire.ConversationSubsystem.AdminlessGroups (selectAutopromotionCandidate) import Wire.ConversationSubsystem.Message +import Wire.ConversationSubsystem.Notify qualified as Notify import Wire.ConversationSubsystem.Query qualified as Query import Wire.ConversationSubsystem.Util import Wire.ExternalAccess qualified as E @@ -1176,6 +1178,34 @@ removeMemberQualified responseMode lusr con qcnv victim = qcnv victim +isAdminlessCheckCandidate :: StoredConversation -> Bool +isAdminlessCheckCandidate conv = + conv.metadata.cnvmType == RegularConv + && maybe True (== GroupConversation) conv.metadata.cnvmGroupConvType + +setupAdminlessGroupsCleanup :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member (Error FederationError) r, + Member BrigAPIAccess r, + Member Random r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member BackendNotificationQueueAccess r, + Member FeaturesConfigSubsystem r, + Member (Input (Local ())) r, + Member JobSubsystem r + ) => + Maybe (Local UserId) -> + TeamId -> + Sem r () +setupAdminlessGroupsCleanup mUsr tid = do + teamConvIds <- E.getTeamConversations tid + for_ teamConvIds $ \cnv -> do + lcnv <- qualifyLocal cnv + adminlessTryAutopromote mUsr lcnv $ \_ feature _ -> scheduleDeletion lcnv mUsr tid feature + guardPreventAdminlessGroups :: ( Member ConversationStore r, Member (Error AdminlessConversation) r, @@ -1201,8 +1231,9 @@ guardPreventAdminlessGroups :: Sem r () guardPreventAdminlessGroups responseMode lcnv lusr victim = do conv <- getConversationWithError lcnv - for_ conv.metadata.cnvmTeam $ \tid -> do + when (isAdminlessCheckCandidate conv) $ for_ conv.metadata.cnvmTeam $ \tid -> do (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid + -- we cannot use the onAdminless helper here because this check happens _before_ removing the potential admin when (feature.status == FeatureStatusEnabled && isLeavingLastConversationAdmin (qUnqualified victim) conv) $ do eligibleMembers <- eligibleAdminFallbackMembers lcnv (Just (qUnqualified victim)) conv case (responseMode, eligibleMembers) of @@ -1215,26 +1246,35 @@ guardPreventAdminlessGroups responseMode lcnv lusr victim = do (RemoveMemberEligibleMembersResponse, _ : _) -> throw $ AdminlessConversation (fmap fst eligibleMembers) (RemoveMemberLegacyResponse, []) -> - scheduleDeletion tid feature + scheduleDeletion lcnv (Just lusr) tid feature (RemoveMemberEligibleMembersResponse, []) -> - scheduleDeletion tid feature + scheduleDeletion lcnv (Just lusr) tid feature + +scheduleDeletion :: + ( Member Now r, + Member JobSubsystem r + ) => + Local ConvId -> + Maybe (Local UserId) -> + TeamId -> + LockableFeature PreventAdminlessGroupsConfig -> + Sem r () +scheduleDeletion lcnv mlusr tid feature = do + now <- Now.get + let deletionTimeout = timeoutToNominalDiffTime feature.config.deletionTimeout + scheduledFor = addUTCTime deletionTimeout now + deletionScheduledFor = toUTCTimeMillis scheduledFor + void $ scheduleAdminlessDeletionJob mlusr tid (qUnqualified (tUntagged lcnv)) scheduledFor + for_ feature.config.reminderTimeouts $ + scheduleReminder now deletionScheduledFor deletionTimeout where - scheduleDeletion tid feature = do - now <- Now.get - let deletionTimeout = timeoutToNominalDiffTime feature.config.deletionTimeout - scheduledFor = addUTCTime deletionTimeout now - deletionScheduledFor = toUTCTimeMillis scheduledFor - void $ scheduleAdminlessDeletionJob (Just lusr) tid (qUnqualified (tUntagged lcnv)) scheduledFor - for_ feature.config.reminderTimeouts $ - scheduleReminder now tid deletionScheduledFor deletionTimeout - - scheduleReminder now tid deletionScheduledFor deletionTimeout reminderTimeoutCfg = do + scheduleReminder now deletionScheduledFor deletionTimeout reminderTimeoutCfg = do let reminderTimeout = timeoutToNominalDiffTime reminderTimeoutCfg when (reminderTimeout < deletionTimeout) $ do let reminderAt = addUTCTime (deletionTimeout - reminderTimeout) now void $ scheduleAdminlessReminderJob - (Just lusr) + mlusr tid (qUnqualified (tUntagged lcnv)) deletionScheduledFor @@ -1256,7 +1296,7 @@ onAdminless :: Sem r () onAdminless lcnv action = do conv <- getConversationWithError lcnv - for_ conv.metadata.cnvmTeam $ \tid -> do + when (isAdminlessCheckCandidate conv) $ for_ conv.metadata.cnvmTeam $ \tid -> do (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid let adminExists = any (\member -> member.convRoleName == roleNameWireAdmin) conv.localMembers || any (\member -> member.convRoleName == roleNameWireAdmin) conv.remoteMembers when (feature.status == FeatureStatusEnabled && not adminExists) $ do @@ -1277,7 +1317,7 @@ adminlessTryAutopromote :: ) => Maybe (Local UserId) -> Local ConvId -> - (StoredConversation -> Sem r ()) -> + (StoredConversation -> LockableFeature PreventAdminlessGroupsConfig -> [(Qualified UserId, User.Name)] -> Sem r ()) -> Sem r () adminlessTryAutopromote mlusr lcnv altAction = do onAdminless lcnv $ \conv feature eligibleMembers -> do @@ -1285,20 +1325,47 @@ adminlessTryAutopromote mlusr lcnv altAction = do x : xs -> do seed <- randomWord64 let autopromotionCandidates = selectAutopromotionCandidate seed feature.config.promotionStrategy (x :| xs) - update = (OtherMemberUpdate (Just roleNameWireAdmin)) + update = OtherMemberUpdate (Just roleNameWireAdmin) for_ autopromotionCandidates $ \candidate -> do E.setOtherMember lcnv candidate update - for_ mlusr \lusr -> - sendConversationActionNotifications - (sing @'ConversationMemberUpdateTag) - (tUntagged lusr) - False - Nothing - (qualifyAs lcnv conv) - (convBotsAndMembers conv) - (ConversationMemberUpdate (tUntagged lusr) update) - def - [] -> altAction conv + case mlusr of + Just lusr -> + void $ + sendConversationActionNotifications + (sing @'ConversationMemberUpdateTag) + (tUntagged lusr) + False + Nothing + (qualifyAs lcnv conv) + (convBotsAndMembers conv) + (ConversationMemberUpdate (tUntagged lusr) update) + def + Nothing -> do + now <- Now.get + for_ autopromotionCandidates $ \candidate -> + Notify.pushSystemEvent + Nothing + ( SystemEvent + (tUntagged lcnv) + Nothing + now + conv.metadata.cnvmTeam + (EdSystemMemberUpdate (memberUpdateData candidate update)) + ) + (Set.fromList (map (.id_) conv.localMembers)) + [] -> altAction conv feature eligibleMembers + where + memberUpdateData candidate memberUpdate' = + MemberUpdateData + { misTarget = candidate, + misOtrMutedStatus = Nothing, + misOtrMutedRef = Nothing, + misOtrArchived = Nothing, + misOtrArchivedRef = Nothing, + misHidden = Nothing, + misHiddenRef = Nothing, + misConvRoleName = omuConvRoleName memberUpdate' + } adminlessAutopromoteOrDelete :: ( Member ConversationStore r, @@ -1319,18 +1386,26 @@ adminlessAutopromoteOrDelete :: Sem r () adminlessAutopromoteOrDelete mlusr lcnv = adminlessTryAutopromote mlusr lcnv orAlternativelyDeleteConv where - orAlternativelyDeleteConv conv = do + orAlternativelyDeleteConv conv _ _ = do removeConversation (qualifyAs lcnv conv) - for_ mlusr $ \lusr -> - sendConversationActionNotifications - (sing @'ConversationDeleteTag) - (tUntagged lusr) - False - Nothing - (qualifyAs lcnv conv) - (convBotsAndMembers conv) - () - def + case mlusr of + Just lusr -> + void $ + sendConversationActionNotifications + (sing @'ConversationDeleteTag) + (tUntagged lusr) + False + Nothing + (qualifyAs lcnv conv) + (convBotsAndMembers conv) + () + def + Nothing -> do + now <- Now.get + Notify.pushSystemEvent + Nothing + (SystemEvent (tUntagged lcnv) Nothing now conv.metadata.cnvmTeam EdSystemConvDelete) + (Set.fromList (map (.id_) conv.localMembers)) adminlessAutopromoteOrSendReminder :: ( Member ConversationStore r, @@ -1350,17 +1425,30 @@ adminlessAutopromoteOrSendReminder :: Sem r () adminlessAutopromoteOrSendReminder mlusr lcnv deletionScheduledFor = adminlessTryAutopromote mlusr lcnv orAlternativelySendReminder where - orAlternativelySendReminder conv = for_ mlusr \lusr -> do + orAlternativelySendReminder conv _ _ = do now <- Now.get - let event = - Event - (tUntagged lcnv) - Nothing - (EventFromUser (tUntagged lusr)) - now - (conv.metadata.cnvmTeam) - (EdAdminlessReminder (AdminlessReminder deletionScheduledFor)) - pushConversationEvent Nothing conv event (qualifyAs lcnv (map (.id_) conv.localMembers)) [] + case mlusr of + Just lusr -> do + let event = + Event + (tUntagged lcnv) + Nothing + (EventFromUser (tUntagged lusr)) + now + (conv.metadata.cnvmTeam) + (EdAdminlessReminder (AdminlessReminder deletionScheduledFor)) + pushConversationEvent Nothing conv event (qualifyAs lcnv (map (.id_) conv.localMembers)) [] + Nothing -> + Notify.pushSystemEvent + Nothing + ( SystemEvent + (tUntagged lcnv) + Nothing + now + conv.metadata.cnvmTeam + (EdSystemAdminlessReminder (AdminlessReminder deletionScheduledFor)) + ) + (Set.fromList (map (.id_) conv.localMembers)) -- Use eight random bytes and fold them into a big-endian Word64. This keeps -- the helper small, deterministic under tests, and free of extra Random API. diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs index f022851ec67..208aa912600 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -20,8 +20,10 @@ module Wire.JobSubsystem ( JobSubsystemConfig (..), JobSubsystem (..), + scheduleAdminlessSetupJob, scheduleAdminlessDeletionJob, scheduleAdminlessReminderJob, + cancelAdminlessJobsForTeam, ) where @@ -37,7 +39,9 @@ data JobSubsystemConfig = JobSubsystemConfig } data JobSubsystem m a where + ScheduleAdminlessSetupJob :: Maybe (Local UserId) -> TeamId -> JobSubsystem m () ScheduleAdminlessDeletionJob :: Maybe (Local UserId) -> TeamId -> ConvId -> UTCTime -> JobSubsystem m () ScheduleAdminlessReminderJob :: Maybe (Local UserId) -> TeamId -> ConvId -> UTCTimeMillis -> NominalDiffTime -> UTCTime -> JobSubsystem m () + CancelAdminlessJobsForTeam :: TeamId -> JobSubsystem m () makeSem ''JobSubsystem diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index 730d35f9214..f84134a366d 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -26,6 +26,8 @@ module Wire.JobSubsystem.Interpreter where import Arbiter.Core qualified as ArbiterCore +import Arbiter.Core.Codec (Col (CInt8, CText), col, pval) +import Arbiter.Core.Operations qualified as ArbiterOperations import Data.Id import Data.Json.Util (UTCTimeMillis) import Data.Qualified @@ -47,8 +49,32 @@ interpretJobSubsystem :: interpretJobSubsystem conf = interpret \case + ScheduleAdminlessSetupJob lusr tid -> scheduleAdminlessSetupJob conf lusr tid ScheduleAdminlessDeletionJob lusr tid cid scheduledFor -> scheduleAdminlessDeletionJob conf lusr tid cid scheduledFor ScheduleAdminlessReminderJob lusr tid cid deletionScheduledFor reminderTimeout scheduledFor -> scheduleAdminlessReminderJob conf lusr tid cid deletionScheduledFor reminderTimeout scheduledFor + CancelAdminlessJobsForTeam tid -> cancelAdminlessJobsForTeam conf tid + +scheduleAdminlessSetupJob :: + forall r. + (PGConstraints r, Member (Input RequestId) r) => + JobSubsystemConfig -> + Maybe (Local UserId) -> + TeamId -> + Sem r () +scheduleAdminlessSetupJob JobSubsystemConfig {..} lusr teamId = do + requestId <- input @RequestId + pool <- input @HasqlPoolExt.Pool + let arbiterEnv = mkNewWireArbiterEnv jobSubsystemSchemaName pool + groupKey = "adminless-setup:" <> idToText teamId + arbiterJob = + ( ArbiterCore.defaultGroupedJob + groupKey + (AdminlessSetup (AdminlessSetupJob teamId (tUnqualified <$> lusr) requestId)) + ) + { ArbiterCore.dedupKey = Just . ArbiterCore.IgnoreDuplicate $ adminlessSetupJobDedupKey teamId, + ArbiterCore.maxAttempts = Just 3 + } + embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @(WireArbiter JobRegistry) @JobRegistry @ConversationsJobPayload arbiterJob scheduleAdminlessDeletionJob :: forall r. @@ -102,10 +128,51 @@ scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId deletion } embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @(WireArbiter JobRegistry) @JobRegistry @ConversationsJobPayload arbiterJob +cancelAdminlessJobsForTeam :: + forall r. + (PGConstraints r) => + JobSubsystemConfig -> + TeamId -> + Sem r () +cancelAdminlessJobsForTeam JobSubsystemConfig {..} teamId = do + pool <- input @HasqlPoolExt.Pool + let arbiterEnv = mkNewWireArbiterEnv jobSubsystemSchemaName pool + embed $ runWireArbiter arbiterEnv (cancelAdminlessJobsForTeamInTransaction jobSubsystemSchemaName) + where + cancelAdminlessJobsForTeamInTransaction :: Text -> WireArbiter JobRegistry () + cancelAdminlessJobsForTeamInTransaction schemaName = + ArbiterCore.withDbTransaction $ do + jobIds <- + ArbiterCore.executeQuery + (adminlessJobsForTeamQuery schemaName conversationsQueueName) + [pval CText (idToText teamId)] + (col "id" CInt8) + unless (null jobIds) $ + void $ + ArbiterOperations.cancelJobsBatch schemaName conversationsQueueName jobIds + + adminlessJobsForTeamQuery :: Text -> Text -> Text + adminlessJobsForTeamQuery schemaName tableName = + "SELECT id FROM " + <> quoteIdentifier schemaName + <> "." + <> quoteIdentifier tableName + <> " WHERE claimed_by IS NULL" + <> " AND payload #>> '{data,team_id}' = ?" + <> " AND payload->>'type' IN ('adminless_setup', 'adminless_deletion', 'adminless_reminder')" + <> " FOR UPDATE" + + quoteIdentifier :: Text -> Text + quoteIdentifier identifier = "\"" <> Text.replace "\"" "\"\"" identifier <> "\"" + adminlessJobDedupKey :: Text -> ConvId -> Text adminlessJobDedupKey jobType convId = "adminless-" <> jobType <> ":" <> idToText convId +adminlessSetupJobDedupKey :: TeamId -> Text +adminlessSetupJobDedupKey teamId = + "adminless-setup:" <> idToText teamId + adminlessReminderJobDedupKey :: ConvId -> NominalDiffTime -> Text adminlessReminderJobDedupKey convId reminderTimeout = adminlessJobDedupKey "reminder" convId <> ":" <> Text.pack (show reminderTimeout) diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs index bc7984738c4..21bd096d637 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs @@ -32,19 +32,21 @@ import Data.Text qualified as T import Data.Text.Encoding qualified as Text import Hasql.Connection qualified as HasqlConnection import Hasql.Connection.Settings qualified as HasqlConnectionSettings +import Hasql.Decoders qualified as HasqlDecoders +import Hasql.Encoders qualified as HasqlEncoders import Hasql.Session qualified as HasqlSession import Hasql.Statement qualified as HasqlStatement import Hasql.TH import Imports import System.IO.Error (userError) import System.Timeout (timeout) -import Wire.API.Jobs (JobRegistry) +import Wire.API.Jobs (JobRegistry, conversationsQueueName) -- | Apply all migrations for the job registry before constructing any worker -- pools or accepting jobs. runJobMigrations :: SecretText -> Text -> IO () runJobMigrations connStr schemaName = - withArbiterMigrationLock connStr schemaName $ do + withArbiterMigrationLock connStr schemaName $ \lockConnection -> do result <- ArbiterMigrations.runMigrationsForRegistry (Proxy @JobRegistry) @@ -52,21 +54,52 @@ runJobMigrations connStr schemaName = schemaName ArbiterMigrations.defaultMigrationConfig case result of - ArbiterMigrations.MigrationSuccess -> pure () + ArbiterMigrations.MigrationSuccess -> ensureAdminlessJobsTeamIndex lockConnection ArbiterMigrations.MigrationError err -> throwIO . userError $ "Arbiter migrations failed for schema " <> T.unpack schemaName <> ": " <> err + where + -- Add the lookup index after Arbiter has created the conversations queue. + -- The partial index only covers unclaimed adminless jobs, which are the rows + -- that feature teardown needs to select and cancel. + ensureAdminlessJobsTeamIndex :: HasqlConnection.Connection -> IO () + ensureAdminlessJobsTeamIndex connection = + runRawSql connection $ + "CREATE INDEX IF NOT EXISTS " + <> quoteIdentifier "conversations_adminless_team_id_idx" + <> " ON " + <> quoteIdentifier schemaName + <> "." + <> quoteIdentifier conversationsQueueName + <> " ((payload #>> '{data,team_id}'))" + <> " WHERE claimed_by IS NULL" + <> " AND payload->>'type' IN ('adminless_setup', 'adminless_deletion', 'adminless_reminder')" + + runRawSql :: HasqlConnection.Connection -> Text -> IO () + runRawSql connection sql = do + result <- + HasqlConnection.use connection $ + HasqlSession.statement + () + (HasqlStatement.unpreparable sql HasqlEncoders.noParams HasqlDecoders.noResult) + either + (\err -> throwIO . userError $ "Arbiter SQL statement failed: " <> show err) + pure + result + + quoteIdentifier :: Text -> Text + quoteIdentifier identifier = "\"" <> T.replace "\"" "\"\"" identifier <> "\"" -- | Serialize Arbiter schema migrations across all service instances that can -- schedule or execute jobs. The lock is held on the same dedicated connection -- for the whole migration because PostgreSQL advisory locks are session-scoped. -withArbiterMigrationLock :: SecretText -> Text -> IO a -> IO a +withArbiterMigrationLock :: SecretText -> Text -> (HasqlConnection.Connection -> IO a) -> IO a withArbiterMigrationLock connStr schemaName action = do bracket acquireConnection HasqlConnection.release $ \lockConnection -> do bracket_ (acquireArbiterMigrationLockWithTimeout lockConnection) (runAdvisoryLockStatement lockConnection releaseArbiterMigrationLock) - action + (action lockConnection) where lockId :: Int64 lockId = fromIntegral . Hashable.hash $ ("wire-server:arbiter-migrations:" <> schemaName :: Text) diff --git a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs index bd6689d97e6..650208e4cca 100644 --- a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs @@ -267,10 +267,14 @@ interpretJobSubsystem :: Sem r a interpretJobSubsystem = interpret $ \case + ScheduleAdminlessSetupJob {} -> + pure () ScheduleAdminlessDeletionJob {} -> pure () ScheduleAdminlessReminderJob {} -> pure () + CancelAdminlessJobsForTeam {} -> + pure () interpretRandom :: Sem (Random ': r) a -> diff --git a/services/background-worker/src/Wire/AdminlessJobsWorker.hs b/services/background-worker/src/Wire/AdminlessJobsWorker.hs index 23fe0906440..573f7398f54 100644 --- a/services/background-worker/src/Wire/AdminlessJobsWorker.hs +++ b/services/background-worker/src/Wire/AdminlessJobsWorker.hs @@ -16,7 +16,8 @@ -- along with this program. If not, see . module Wire.AdminlessJobsWorker - ( runAdminlessDeletionJob, + ( runAdminlessSetupJob, + runAdminlessDeletionJob, runAdminlessReminderJob, ) where @@ -26,12 +27,33 @@ import Arbiter.Core.Job.Types (JobRead, notVisibleUntil, payload) import Data.Qualified (toLocalUnsafe) import Imports import System.Logger qualified as Log -import Wire.API.Jobs (AdminlessDeletionJob (..), AdminlessReminderJob (..)) +import Wire.API.Jobs (AdminlessDeletionJob (..), AdminlessReminderJob (..), AdminlessSetupJob (..)) import Wire.BackgroundWorker.Env (AppT, Env (..)) import Wire.ConversationSubsystem import Wire.Effects (runBackgroundWorkerEffects) import Wire.ExternalAccess.External (ExtEnv) +runAdminlessSetupJob :: ExtEnv -> JobRead AdminlessSetupJob -> AppT IO () +runAdminlessSetupJob extEnv job = do + env <- ask + Log.debug env.logger $ + Log.msg (Log.val "Running adminless setup job") + . Log.field "team_id" (show job.payload.adminlessSetupJobTeamId) + . Log.field "orig_user_id" (show job.payload.adminlessSetupJobOrigUserId) + . Log.field "request_id" (show job.payload.adminlessSetupJobRequestId) + . Log.field "scheduled_for" (show job.notVisibleUntil) + result <- + liftIO $ + runBackgroundWorkerEffects env extEnv job.payload.adminlessSetupJobRequestId Nothing $ + do + setupAdminlessGroupsCleanup + (toLocalUnsafe env.federationDomain <$> job.payload.adminlessSetupJobOrigUserId) + job.payload.adminlessSetupJobTeamId + Log.debug env.logger $ + Log.msg (Log.val "Adminless setup job finished") + . Log.field "team_id" (show job.payload.adminlessSetupJobTeamId) + either (liftIO . throwRetryable) pure result + runAdminlessDeletionJob :: ExtEnv -> JobRead AdminlessDeletionJob -> AppT IO () runAdminlessDeletionJob extEnv job = do env <- ask diff --git a/services/background-worker/src/Wire/BackgroundWorker/Workers.hs b/services/background-worker/src/Wire/BackgroundWorker/Workers.hs index 5ff16810883..ea2604e1d07 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Workers.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Workers.hs @@ -43,7 +43,7 @@ import System.IO.Error (userError) import System.Logger qualified as Log import UnliftIO.Async qualified as Async import Wire.API.Jobs -import Wire.AdminlessJobsWorker (runAdminlessDeletionJob, runAdminlessReminderJob) +import Wire.AdminlessJobsWorker (runAdminlessDeletionJob, runAdminlessReminderJob, runAdminlessSetupJob) import Wire.BackgroundWorker.Env (AppT, Env (..), runAppT) import Wire.BackgroundWorker.Options (JobConfig (..), JobJitter (..), MeetingsCleanupConfig (..)) import Wire.BackgroundWorker.Util @@ -170,6 +170,7 @@ runJobRunner env extEnv runnerConfig cleanupConfig = do . Log.field "queue_name" conversationsQueueName . Log.field "payload_type" (conversationsJobPayloadTypeName job.payload) case job.payload of + AdminlessSetup payload -> runAppT env $ runAdminlessSetupJob extEnv (mapJobPayload (const payload) job) AdminlessDeletion payload -> runAppT env $ runAdminlessDeletionJob extEnv (mapJobPayload (const payload) job) AdminlessReminder payload -> runAppT env $ runAdminlessReminderJob extEnv (mapJobPayload (const payload) job) @@ -249,6 +250,7 @@ meetingsJobPayloadTypeName = \case conversationsJobPayloadTypeName :: ConversationsJobPayload -> Text conversationsJobPayloadTypeName = \case + AdminlessSetup _ -> "adminless_setup" AdminlessDeletion _ -> "adminless_deletion" AdminlessReminder _ -> "adminless_reminder" diff --git a/services/galley/src/Galley/API/Teams/Features.hs b/services/galley/src/Galley/API/Teams/Features.hs index 15c4ce548cf..48816c4733f 100644 --- a/services/galley/src/Galley/API/Teams/Features.hs +++ b/services/galley/src/Galley/API/Teams/Features.hs @@ -37,7 +37,7 @@ import Data.Default import Data.Id import Data.Json.Util import Data.Kind -import Data.Qualified (Local) +import Data.Qualified import Galley.API.LegalHold qualified as LegalHold import Galley.API.LegalHold.Team qualified as LegalHold import Galley.App @@ -72,6 +72,7 @@ import Wire.FeaturesConfigSubsystem.Utils (resolveServerFeature) import Wire.FederationAPIAccess (FederationAPIAccess) import Wire.FederationSubsystem (FederationSubsystem) import Wire.FireAndForget +import Wire.JobSubsystem (JobSubsystem, cancelAdminlessJobsForTeam, scheduleAdminlessSetupJob) import Wire.LegalHoldStore (LegalHoldStore) import Wire.NotificationSubsystem import Wire.Options.Galley @@ -87,6 +88,7 @@ import Wire.TeamStore (TeamStore) import Wire.TeamStore qualified as SearchVisibilityData import Wire.TeamSubsystem (TeamSubsystem) import Wire.TeamSubsystem qualified as TeamSubsystem +import Wire.Util type ComputeFeatureConstraints cfg r = (Member FeaturesConfigSubsystem r) @@ -114,6 +116,7 @@ patchFeatureInternal tid patch = do prepareFeature tid patchedFeature patchDbFeature tid patch (returnedFeature :: LockableFeature cfg) <- getFeatureForTeam tid + afterFeatureSet @cfg Nothing tid dbFeatureWithDefaults returnedFeature pushFeatureEvent @cfg tid (mkUpdateEvent tid returnedFeature) pure returnedFeature where @@ -137,16 +140,18 @@ setFeature :: Member TeamFeatureStore r, Member P.TinyLog r, Member NotificationSubsystem r, - Member TeamSubsystem r + Member TeamSubsystem r, + Member (Input (Local ())) r ) => UserId -> TeamId -> Feature cfg -> Sem r (LockableFeature cfg) setFeature uid tid feat = do + lusr <- qualifyLocal uid zusrMembership <- TeamSubsystem.internalGetTeamMember uid tid void $ TeamSubsystem.permissionCheck ChangeTeamFeature zusrMembership - setFeatureUnchecked tid feat + setFeatureUnchecked (Just lusr) tid feat setFeatureInternal :: forall cfg r. @@ -165,7 +170,7 @@ setFeatureInternal :: Sem r (LockableFeature cfg) setFeatureInternal tid feat = do TeamSubsystem.assertTeamExists tid - setFeatureUnchecked tid feat + setFeatureUnchecked Nothing tid feat setFeatureUnchecked :: forall cfg r. @@ -179,13 +184,14 @@ setFeatureUnchecked :: Member NotificationSubsystem r, Member TeamSubsystem r ) => + Maybe (Local UserId) -> TeamId -> Feature cfg -> Sem r (LockableFeature cfg) -setFeatureUnchecked tid feat = do +setFeatureUnchecked originUser tid feat = do (feat0 :: LockableFeature cfg) <- getFeatureForTeam tid guardLockStatus feat0.lockStatus - setFeatureForTeam @cfg tid (withLockStatus feat0.lockStatus feat) + setFeatureForTeam @cfg originUser tid feat0 (withLockStatus feat0.lockStatus feat) updateLockStatus :: forall cfg r. @@ -258,12 +264,15 @@ setFeatureForTeam :: Member TeamFeatureStore r, Member TeamSubsystem r ) => + Maybe (Local UserId) -> TeamId -> LockableFeature cfg -> + LockableFeature cfg -> Sem r (LockableFeature cfg) -setFeatureForTeam tid feat = do +setFeatureForTeam originUser tid oldFeature feat = do prepareFeature tid feat newFeat <- persistFeature tid feat + afterFeatureSet @cfg originUser tid oldFeature newFeat pushFeatureEvent @cfg tid (mkUpdateEvent tid newFeat) pure newFeat @@ -289,6 +298,21 @@ class (GetFeatureConfig cfg) => SetFeatureConfig cfg where default prepareFeature :: TeamId -> LockableFeature cfg -> Sem r () prepareFeature _tid _feat = pure () + afterFeatureSet :: + (SetFeatureForTeamConstraints cfg r) => + Maybe (Local UserId) -> + TeamId -> + LockableFeature cfg -> + LockableFeature cfg -> + Sem r () + default afterFeatureSet :: + Maybe (Local UserId) -> + TeamId -> + LockableFeature cfg -> + LockableFeature cfg -> + Sem r () + afterFeatureSet _originUser _tid _oldFeature _newFeature = pure () + instance SetFeatureConfig SSOConfig where type SetFeatureForTeamConstraints SSOConfig (r :: EffectRow) = @@ -420,7 +444,21 @@ instance SetFeatureConfig MLSConfig where instance SetFeatureConfig ChannelsConfig -instance SetFeatureConfig PreventAdminlessGroupsConfig +instance SetFeatureConfig PreventAdminlessGroupsConfig where + type SetFeatureForTeamConstraints PreventAdminlessGroupsConfig r = Member JobSubsystem r + + afterFeatureSet originUser tid oldFeature newFeature = + case (oldFeature.status, newFeature.status) of + (FeatureStatusDisabled, FeatureStatusEnabled) -> + scheduleAdminlessSetupJob originUser tid + (FeatureStatusEnabled, FeatureStatusDisabled) -> + cancelAdminlessJobsForTeam tid + (FeatureStatusEnabled, FeatureStatusEnabled) + | oldFeature.config /= newFeature.config -> do + cancelAdminlessJobsForTeam tid + scheduleAdminlessSetupJob originUser tid + | otherwise -> pure () + _ -> pure () instance SetFeatureConfig ExposeInvitationURLsToTeamAdminConfig From 539fca7c7cefd966611e76c93b30c33814319791 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 24 Jul 2026 12:58:30 +0200 Subject: [PATCH 029/113] WPB-26101 move code from KeyPackage into subsystem and store (#5368) --- changelog.d/5-internal/WPB-26101 | 1 + libs/types-common/src/Data/Range.hs | 6 +- .../src/Wire/MlsKeyPackageStore.hs | 34 ++++ .../src/Wire/MlsKeyPackageStore/Cassandra.hs | 72 ++++++++ .../src/Wire/MlsKeyPackageSubsystem.hs | 44 +++++ .../MlsKeyPackageSubsystem/Interpreter.hs | 114 ++++++++++++ libs/wire-subsystems/wire-subsystems.cabal | 4 + services/brig/brig.cabal | 1 - services/brig/src/Brig/API/Federation.hs | 11 +- services/brig/src/Brig/API/Internal.hs | 16 +- services/brig/src/Brig/API/MLS/KeyPackages.hs | 30 ++-- .../Brig/API/MLS/KeyPackages/Validation.hs | 10 +- services/brig/src/Brig/API/Public.hs | 2 + .../brig/src/Brig/CanonicalInterpreter.hs | 8 + services/brig/src/Brig/Data/MLS/KeyPackage.hs | 169 ------------------ 15 files changed, 320 insertions(+), 202 deletions(-) create mode 100644 changelog.d/5-internal/WPB-26101 create mode 100644 libs/wire-subsystems/src/Wire/MlsKeyPackageStore.hs create mode 100644 libs/wire-subsystems/src/Wire/MlsKeyPackageStore/Cassandra.hs create mode 100644 libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem.hs create mode 100644 libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem/Interpreter.hs delete mode 100644 services/brig/src/Brig/Data/MLS/KeyPackage.hs diff --git a/changelog.d/5-internal/WPB-26101 b/changelog.d/5-internal/WPB-26101 new file mode 100644 index 00000000000..9cf9e14fcb2 --- /dev/null +++ b/changelog.d/5-internal/WPB-26101 @@ -0,0 +1 @@ +Extract MLS key-package handling into a subsystem and split Cassandra access into a dedicated store. diff --git a/libs/types-common/src/Data/Range.hs b/libs/types-common/src/Data/Range.hs index ed74973b2e7..9a3794ca2f2 100644 --- a/libs/types-common/src/Data/Range.hs +++ b/libs/types-common/src/Data/Range.hs @@ -507,7 +507,11 @@ genRangeText :: (KnownNat n, KnownNat m, n <= m) => Gen Char -> Gen (Range n m Text) -genRangeText = genRange fromString +genRangeText genChar = genRange fromString (QC.suchThat genChar isUnicodeScalarValue) + where + isUnicodeScalarValue c = + let codePoint = ord c + in codePoint < 0xD800 || codePoint > 0xDFFF instance (AsciiChars c, KnownNat n, KnownNat m, n <= m, Arbitrary (AsciiChar c)) => diff --git a/libs/wire-subsystems/src/Wire/MlsKeyPackageStore.hs b/libs/wire-subsystems/src/Wire/MlsKeyPackageStore.hs new file mode 100644 index 00000000000..a0bba004beb --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MlsKeyPackageStore.hs @@ -0,0 +1,34 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MlsKeyPackageStore where + +import Data.Id +import Polysemy +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.KeyPackage + +data MlsKeyPackageStore m a where + InsertKeyPackages :: UserId -> ClientId -> [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> MlsKeyPackageStore m () + LookupKeyPackages :: UserId -> ClientId -> CipherSuiteTag -> MlsKeyPackageStore m [(KeyPackageRef, KeyPackageData)] + DeleteKeyPackages :: UserId -> ClientId -> CipherSuiteTag -> [KeyPackageRef] -> MlsKeyPackageStore m () + DeleteAllKeyPackages :: UserId -> ClientId -> [CipherSuiteTag] -> MlsKeyPackageStore m () + DeleteKeyPackage :: UserId -> ClientId -> CipherSuiteTag -> KeyPackageRef -> MlsKeyPackageStore m () + +makeSem ''MlsKeyPackageStore diff --git a/libs/wire-subsystems/src/Wire/MlsKeyPackageStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/MlsKeyPackageStore/Cassandra.hs new file mode 100644 index 00000000000..25e0800147b --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MlsKeyPackageStore/Cassandra.hs @@ -0,0 +1,72 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MlsKeyPackageStore.Cassandra (interpretMlsKeyPackageStoreToCassandra) where + +import Cassandra as C hiding (Client) +import Data.Id +import Imports +import Polysemy +import Polysemy.Embed +import UnliftIO.Async (pooledForConcurrentlyN_) +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.KeyPackage (KeyPackageData, KeyPackageRef) +import Wire.MlsKeyPackageStore (MlsKeyPackageStore (..)) + +interpretMlsKeyPackageStoreToCassandra :: (Member (Embed IO) r) => ClientState -> InterpreterFor MlsKeyPackageStore r +interpretMlsKeyPackageStoreToCassandra cas = + interpret $ + runEmbedded (runClient cas) . \case + InsertKeyPackages u c ps -> embed $ insertKeyPackages u c ps + LookupKeyPackages u c s -> embed $ lookupKeyPackages u c s + DeleteKeyPackages u c s rs -> embed $ deleteKeyPackages u c s rs + DeleteAllKeyPackages u c ss -> embed $ deleteAllKeyPackages u c ss + DeleteKeyPackage u c s r -> embed $ deleteKeyPackage u c s r + +insertKeyPackages :: (MonadClient m) => UserId -> ClientId -> [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> m () +insertKeyPackages u c ps = retry x5 . batch $ do + setType BatchLogged + setConsistency LocalQuorum + for_ ps $ \(r, s, p) -> addPrepQuery insertQuery (u, c, s, p, r) + +lookupKeyPackages :: (MonadClient m) => UserId -> ClientId -> CipherSuiteTag -> m [(KeyPackageRef, KeyPackageData)] +lookupKeyPackages u c s = retry x1 $ query lookupQuery (params LocalQuorum (u, c, s)) + +deleteKeyPackages :: (MonadClient m) => UserId -> ClientId -> CipherSuiteTag -> [KeyPackageRef] -> m () +deleteKeyPackages u c s rs = retry x5 $ write deleteQuery (params LocalQuorum (u, c, s, rs)) + +deleteAllKeyPackages :: (MonadClient m, MonadUnliftIO m, Foldable f) => UserId -> ClientId -> f CipherSuiteTag -> m () +deleteAllKeyPackages u c ss = pooledForConcurrentlyN_ 16 ss $ \s -> retry x5 $ write deleteAllQuery (params LocalQuorum (u, c, s)) + +deleteKeyPackage :: (MonadClient m) => UserId -> ClientId -> CipherSuiteTag -> KeyPackageRef -> m () +deleteKeyPackage u c s r = do + retry x5 $ write deleteKeyPackageQuery (params LocalQuorum (u, c, s, r)) + +insertQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag, KeyPackageData, KeyPackageRef) () +insertQuery = "INSERT INTO mls_key_packages (user, client, cipher_suite, data, ref) VALUES (?, ?, ?, ?, ?)" + +lookupQuery :: PrepQuery R (UserId, ClientId, CipherSuiteTag) (KeyPackageRef, KeyPackageData) +lookupQuery = "SELECT ref, data FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ?" + +deleteQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag, [KeyPackageRef]) () +deleteQuery = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ? AND ref IN ?" + +deleteAllQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag) () +deleteAllQuery = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ?" + +deleteKeyPackageQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag, KeyPackageRef) () +deleteKeyPackageQuery = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ? AND ref = ?" diff --git a/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem.hs b/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem.hs new file mode 100644 index 00000000000..6c5b920f2bb --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem.hs @@ -0,0 +1,44 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MlsKeyPackageSubsystem where + +import Data.Id +import Data.Time.Clock (NominalDiffTime) +import Data.Time.Clock.POSIX (POSIXTime) +import Imports +import Polysemy +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.KeyPackage +import Wire.API.MLS.Lifetime + +data MlsKeyPackageSubsystem m a where + InsertMlsKeyPackages :: UserId -> ClientId -> [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> MlsKeyPackageSubsystem m () + ClaimMlsKeyPackage :: UserId -> ClientId -> CipherSuiteTag -> MlsKeyPackageSubsystem m (Maybe (KeyPackageRef, KeyPackageData)) + CountMlsKeyPackages :: UserId -> ClientId -> CipherSuiteTag -> MlsKeyPackageSubsystem m Int64 + DeleteMlsKeyPackages :: UserId -> ClientId -> CipherSuiteTag -> [KeyPackageRef] -> MlsKeyPackageSubsystem m () + DeleteAllMlsKeyPackages :: UserId -> ClientId -> [CipherSuiteTag] -> MlsKeyPackageSubsystem m () + +makeSem ''MlsKeyPackageSubsystem + +validateKeyPackageLifetime :: POSIXTime -> Maybe NominalDiffTime -> Lifetime -> Either Text () +validateKeyPackageLifetime now maxLifetime lifetime = do + when (tsPOSIX lifetime.ltNotBefore > now) $ Left "Key package not_before date is in the future" + when (tsPOSIX lifetime.ltNotAfter <= now) $ Left "Key package is expired" + for_ maxLifetime $ \maxAge -> when (tsPOSIX lifetime.ltNotAfter > now + maxAge) $ Left "Key package expiration time is too far in the future" diff --git a/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem/Interpreter.hs new file mode 100644 index 00000000000..d0b58564be4 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem/Interpreter.hs @@ -0,0 +1,114 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MlsKeyPackageSubsystem.Interpreter (interpretMlsKeyPackageSubsystem) where + +import Control.Concurrent qualified as C +import Control.Error (atMay) +import Control.Monad.Random (randomRIO) +import Data.Id +import Data.Time.Clock (NominalDiffTime) +import Data.Time.Clock.POSIX +import Imports +import Polysemy +import Polysemy.Resource (Resource, bracket) +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.KeyPackage +import Wire.API.MLS.LeafNode +import Wire.API.MLS.Serialisation +import Wire.MlsKeyPackageStore qualified as Store +import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem (..), validateKeyPackageLifetime) + +interpretMlsKeyPackageSubsystem :: (Member Store.MlsKeyPackageStore r, Member (Embed IO) r, Member Resource r) => Maybe NominalDiffTime -> C.MVar () -> InterpreterFor MlsKeyPackageSubsystem r +interpretMlsKeyPackageSubsystem configuredLifetime lock = interpret $ \case + InsertMlsKeyPackages u c ps -> insertMlsKeyPackages u c ps + ClaimMlsKeyPackage u c s -> claimMlsKeyPackage configuredLifetime lock u c s + CountMlsKeyPackages u c s -> countMlsKeyPackages configuredLifetime u c s + DeleteMlsKeyPackages u c s rs -> deleteMlsKeyPackages u c s rs + DeleteAllMlsKeyPackages u c ss -> deleteAllMlsKeyPackages u c ss + +insertMlsKeyPackages :: (Member Store.MlsKeyPackageStore r) => UserId -> ClientId -> [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> Sem r () +insertMlsKeyPackages u c ps = Store.insertKeyPackages u c ps + +claimMlsKeyPackage :: + (Member Store.MlsKeyPackageStore r, Member (Embed IO) r, Member Resource r) => + Maybe NominalDiffTime -> + C.MVar () -> + UserId -> + ClientId -> + CipherSuiteTag -> + Sem r (Maybe (KeyPackageRef, KeyPackageData)) +claimMlsKeyPackage maxLifetime lock u c s = + bracket (embed $ C.takeMVar lock) (embed . C.putMVar lock) (const claim) + where + claim :: (Member Store.MlsKeyPackageStore r, Member (Embed IO) r) => Sem r (Maybe (KeyPackageRef, KeyPackageData)) + claim = do + candidates <- getNonClaimedKeyPackages maxLifetime u c s + case candidates of + [] -> pure Nothing + _ -> do + mk <- embed (pick candidates) + for mk $ \candidate -> do + Store.deleteKeyPackage u c s (fst candidate) + pure candidate + pick :: [a] -> IO (Maybe a) + pick [] = pure Nothing + pick xs = do + i <- randomRIO (0, length xs - 1) + pure (atMay xs i) + +countMlsKeyPackages :: + ( Member Store.MlsKeyPackageStore r, + Member (Embed IO) r + ) => + Maybe NominalDiffTime -> + UserId -> + ClientId -> + CipherSuiteTag -> + Sem r Int64 +countMlsKeyPackages configuredLifetime u c s = fromIntegral . length <$> getNonClaimedKeyPackages configuredLifetime u c s + +deleteMlsKeyPackages :: + (Member Store.MlsKeyPackageStore r) => + UserId -> + ClientId -> + CipherSuiteTag -> + [KeyPackageRef] -> + Sem r () +deleteMlsKeyPackages u c s rs = Store.deleteKeyPackages u c s rs + +deleteAllMlsKeyPackages :: (Member Store.MlsKeyPackageStore r) => UserId -> ClientId -> [CipherSuiteTag] -> Sem r () +deleteAllMlsKeyPackages u c ss = Store.deleteAllKeyPackages u c ss + +-- | Fetch all unclaimed non-expired key packages for a given client and delete +-- from the database those that have expired. +getNonClaimedKeyPackages :: (Member Store.MlsKeyPackageStore r, Member (Embed IO) r) => Maybe NominalDiffTime -> UserId -> ClientId -> CipherSuiteTag -> Sem r [(KeyPackageRef, KeyPackageData)] +getNonClaimedKeyPackages maxLifetime u c s = do + rows <- Store.lookupKeyPackages u c s + now <- embed getPOSIXTime + let decoded = mapMaybe decode rows + (expired, usable) = partition (isExpired now maxLifetime) decoded + Store.deleteKeyPackages u c s (map (fst . snd) expired) + pure (map snd usable) + where + decode :: (KeyPackageRef, KeyPackageData) -> Maybe (KeyPackage, (KeyPackageRef, KeyPackageData)) + decode row@(_, packageData) = do + package <- either (const Nothing) Just (decodeMLS' (kpData packageData) :: Either Text (RawMLS KeyPackage)) + pure (package.value, row) + isExpired now configuredLifetime (package, _) = case package.leafNode.source of + LeafNodeSourceKeyPackage lifetime -> isLeft (validateKeyPackageLifetime now configuredLifetime lifetime) + _ -> True diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 2479b2e23e1..531d31839b7 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -384,6 +384,10 @@ library Wire.MeetingsSubsystem.Interpreter Wire.Migration Wire.MigrationLock + Wire.MlsKeyPackageStore + Wire.MlsKeyPackageStore.Cassandra + Wire.MlsKeyPackageSubsystem + Wire.MlsKeyPackageSubsystem.Interpreter Wire.NotificationSubsystem Wire.NotificationSubsystem.Interpreter Wire.Options.Galley diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index 7868d06447c..a2baccf5dc5 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -105,7 +105,6 @@ library Brig.CanonicalInterpreter Brig.Data.Activation Brig.Data.Connection - Brig.Data.MLS.KeyPackage Brig.Data.Nonce Brig.Data.Types Brig.Data.User diff --git a/services/brig/src/Brig/API/Federation.hs b/services/brig/src/Brig/API/Federation.hs index 3641348b490..d109db22531 100644 --- a/services/brig/src/Brig/API/Federation.hs +++ b/services/brig/src/Brig/API/Federation.hs @@ -72,6 +72,7 @@ import Wire.Error import Wire.FederationConfigStore (FederationConfigStore) import Wire.FederationConfigStore qualified as E import Wire.GalleyAPIAccess (GalleyAPIAccess) +import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem) import Wire.NotificationSubsystem import Wire.Sem.Concurrency import Wire.UserStore @@ -89,6 +90,7 @@ federationSitemap :: Member UserSubsystem r, Member UserStore r, Member ClientStore r, + Member MlsKeyPackageSubsystem r, Member ClientSubsystem r ) => ServerT FederationAPI (Handler r) @@ -203,7 +205,8 @@ claimMultiPrekeyBundle _ uc = lift $ liftSem $ ClientSubsystem.claimLocalMultiPr fedClaimKeyPackages :: ( Member GalleyAPIAccess r, Member UserStore r, - Member ClientStore r + Member ClientStore r, + Member MlsKeyPackageSubsystem r ) => Domain -> ClaimKeyPackageRequest -> @@ -283,15 +286,15 @@ searchUsers domain (SearchRequest searchTerm mTeam mOnlyInTeams mbUserTypeFilter getUserClients :: (Member ClientSubsystem r) => Domain -> GetUserClients -> (Handler r) (UserMap (Set PubClient)) getUserClients _ (GetUserClients uids) = lift (liftSem $ ClientSubsystem.lookupLocalPublicClientsBulk uids) !>> clientErrorToHttpError -getMLSClients :: (Member ClientStore r) => Domain -> MLSClientsRequest -> Handler r (Set ClientInfo) +getMLSClients :: (Member ClientStore r, Member MlsKeyPackageSubsystem r) => Domain -> MLSClientsRequest -> Handler r (Set ClientInfo) getMLSClients _domain mcr = do Internal.getMLSClientsH mcr.userId mcr.cipherSuite -getMLSClient :: (Member ClientStore r) => Domain -> MLSClientRequest -> Handler r ClientInfo +getMLSClient :: (Member ClientStore r, Member MlsKeyPackageSubsystem r) => Domain -> MLSClientRequest -> Handler r ClientInfo getMLSClient _domain mcr = Internal.getMLSClientH mcr.userId mcr.clientId mcr.cipherSuite -getMLSClientsV0 :: (Member ClientStore r) => Domain -> MLSClientsRequestV0 -> Handler r (Set ClientInfo) +getMLSClientsV0 :: (Member ClientStore r, Member MlsKeyPackageSubsystem r) => Domain -> MLSClientsRequestV0 -> Handler r (Set ClientInfo) getMLSClientsV0 domain mcr0 = getMLSClients domain (mlsClientsRequestFromV0 mcr0) onUserDeleted :: diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index f175554db71..1a1a68f1bef 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -34,7 +34,6 @@ import Brig.API.User qualified as API import Brig.App as App import Brig.Data.Activation import Brig.Data.Connection qualified as Data -import Brig.Data.MLS.KeyPackage qualified as Data import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) import Brig.Options hiding (internalEvents) import Brig.Provider.API qualified as Provider @@ -119,6 +118,8 @@ import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.HashPassword (HashPassword) import Wire.IndexedUserStore (IndexedUserStore, getTeamSize) import Wire.InvitationStore +import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem) +import Wire.MlsKeyPackageSubsystem qualified as Mls import Wire.NotificationSubsystem import Wire.PasswordResetCodeStore (PasswordResetCodeStore) import Wire.PropertySubsystem @@ -188,7 +189,8 @@ servantSitemap :: Member AppStore r, Member AppSubsystem r, Member ClientStore r, - Member ClientSubsystem r + Member ClientSubsystem r, + Member MlsKeyPackageSubsystem r ) => ServerT BrigIRoutes.API (Handler r) servantSitemap = @@ -224,7 +226,7 @@ ejpdAPI :: ServerT BrigIRoutes.EJPDRequest (Handler r) ejpdAPI = Named @"ejpd-request" Brig.User.EJPD.ejpdRequest -mlsAPI :: (Member ClientStore r) => ServerT BrigIRoutes.MLSAPI (Handler r) +mlsAPI :: (Member ClientStore r, Member MlsKeyPackageSubsystem r) => ServerT BrigIRoutes.MLSAPI (Handler r) mlsAPI = Named @"get-mls-clients" getMLSClientsH :<|> Named @"get-mls-client" getMLSClientH @@ -461,13 +463,13 @@ deleteAccountConferenceCallingConfig :: (Member UserStore r) => UserId -> Handle deleteAccountConferenceCallingConfig uid = lift . liftSem $ UserStore.updateFeatureConferenceCalling uid Nothing $> NoContent -getMLSClientH :: (Member ClientStore r) => UserId -> ClientId -> CipherSuite -> Handler r ClientInfo +getMLSClientH :: (Member ClientStore r, Member MlsKeyPackageSubsystem r) => UserId -> ClientId -> CipherSuite -> Handler r ClientInfo getMLSClientH usr cid suite = do lusr <- qualifyLocal usr suiteTag <- maybe (mlsProtocolError "Unknown ciphersuite") pure (cipherSuiteTag suite) lift $ getMLSClient lusr cid suiteTag -getMLSClientsH :: (Member ClientStore r) => UserId -> CipherSuite -> Handler r (Set ClientInfo) +getMLSClientsH :: (Member ClientStore r, Member MlsKeyPackageSubsystem r) => UserId -> CipherSuite -> Handler r (Set ClientInfo) getMLSClientsH usr suite = do lusr <- qualifyLocal usr suiteTag <- maybe (mlsProtocolError "Unknown ciphersuite") pure (cipherSuiteTag suite) @@ -476,13 +478,13 @@ getMLSClientsH usr suite = do pure $ Set.fromList clientInfos getMLSClient :: - (Member ClientStore r) => + (Member ClientStore r, Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> CipherSuiteTag -> AppT r ClientInfo getMLSClient lusr cid suiteTag = do - numKeyPackages <- wrapClient $ Data.countKeyPackages lusr cid suiteTag + numKeyPackages <- liftSem $ Mls.countMlsKeyPackages (tUnqualified lusr) cid suiteTag mc <- liftSem $ ClientStore.lookupClient (tUnqualified lusr) cid let keys = foldMap (.clientMLSPublicKeys) mc ss = csSignatureScheme suiteTag diff --git a/services/brig/src/Brig/API/MLS/KeyPackages.hs b/services/brig/src/Brig/API/MLS/KeyPackages.hs index 7a592321328..26c94b8daba 100644 --- a/services/brig/src/Brig/API/MLS/KeyPackages.hs +++ b/services/brig/src/Brig/API/MLS/KeyPackages.hs @@ -35,7 +35,6 @@ import Brig.API.MLS.KeyPackages.Validation import Brig.API.MLS.Util import Brig.API.Types import Brig.App -import Brig.Data.MLS.KeyPackage qualified as Data import Control.Monad.Trans.Except import Control.Monad.Trans.Maybe import Data.CommaSeparatedList @@ -60,20 +59,23 @@ import Wire.ClientSubsystem.Error import Wire.FederationAPIAccess import Wire.GalleyAPIAccess (GalleyAPIAccess, getUserLegalholdStatus) import Wire.GalleyAPIAccess qualified as GalleyAPIAccess +import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem) +import Wire.MlsKeyPackageSubsystem qualified as Mls import Wire.StoredUser import Wire.UserStore (UserStore, getUser) -uploadKeyPackages :: (Member ClientStore r) => Local UserId -> ClientId -> KeyPackageUpload -> Handler r () +uploadKeyPackages :: (Member ClientStore r, Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> KeyPackageUpload -> Handler r () uploadKeyPackages lusr cid kps = do assertMLSEnabled let identity = mkClientIdentity (tUntagged lusr) cid kps' <- traverse (validateUploadedKeyPackage identity) kps.keyPackages - lift . wrapClient $ Data.insertKeyPackages (tUnqualified lusr) cid kps' + lift $ liftSem $ Mls.insertMlsKeyPackages (tUnqualified lusr) cid kps' claimKeyPackages :: ( Member GalleyAPIAccess r, Member UserStore r, Member ClientStore r, + Member MlsKeyPackageSubsystem r, HasBrigFederationAccess m r ) => Local UserId -> @@ -87,6 +89,7 @@ claimKeyPackagesV7 :: ( Member GalleyAPIAccess r, Member UserStore r, Member ClientStore r, + Member MlsKeyPackageSubsystem r, HasBrigFederationAccess m r ) => Local UserId -> @@ -108,7 +111,8 @@ claimLocalKeyPackages :: forall r. ( Member GalleyAPIAccess r, Member UserStore r, - Member ClientStore r + Member ClientStore r, + Member MlsKeyPackageSubsystem r ) => Qualified UserId -> Maybe ClientId -> @@ -157,7 +161,7 @@ claimLocalKeyPackages qusr skipOwn suite qTarget = do runMaybeT $ do guard $ Just c /= own uncurry (KeyPackageBundleEntry (tUntagged qTarget) c) - <$> wrapClientM (Data.claimKeyPackage qTarget c suite) + <$> MaybeT (liftSem $ Mls.claimMlsKeyPackage (tUnqualified qTarget) c suite) -- FUTUREWORK: shouldn't this be defined elsewhere for general use? assertUserNotUnderLegalHold :: StoredUser -> ExceptT ClientError (AppT r) () @@ -211,18 +215,19 @@ claimRemoteKeyPackages lusr suite target = do pure bundle -countKeyPackages :: Local UserId -> ClientId -> CipherSuite -> Handler r KeyPackageCount +countKeyPackages :: (Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> CipherSuite -> Handler r KeyPackageCount countKeyPackages lusr c = countKeyPackagesV7 lusr c . Just -countKeyPackagesV7 :: Local UserId -> ClientId -> Maybe CipherSuite -> Handler r KeyPackageCount +countKeyPackagesV7 :: (Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> Maybe CipherSuite -> Handler r KeyPackageCount countKeyPackagesV7 lusr c mSuite = do assertMLSEnabled suite <- getCipherSuite mSuite lift $ KeyPackageCount . fromIntegral - <$> wrapClient (Data.countKeyPackages lusr c suite) + <$> liftSem (Mls.countMlsKeyPackages (tUnqualified lusr) c suite) deleteKeyPackages :: + (Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> CipherSuite -> @@ -231,6 +236,7 @@ deleteKeyPackages :: deleteKeyPackages lusr c = deleteKeyPackagesV7 lusr c . Just deleteKeyPackagesV7 :: + (Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> Maybe CipherSuite -> @@ -239,10 +245,10 @@ deleteKeyPackagesV7 :: deleteKeyPackagesV7 lusr c mSuite (unDeleteKeyPackages -> refs) = do assertMLSEnabled suite <- getCipherSuite mSuite - lift $ wrapClient (Data.deleteKeyPackages (tUnqualified lusr) c suite refs) + lift $ liftSem (Mls.deleteMlsKeyPackages (tUnqualified lusr) c suite refs) replaceKeyPackages :: - (Member ClientStore r) => + (Member ClientStore r, Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> CommaSeparatedList CipherSuite -> @@ -251,7 +257,7 @@ replaceKeyPackages :: replaceKeyPackages lusr c = replaceKeyPackagesV7 lusr c . Just replaceKeyPackagesV7 :: - (Member ClientStore r) => + (Member ClientStore r, Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> Maybe (CommaSeparatedList CipherSuite) -> @@ -260,5 +266,5 @@ replaceKeyPackagesV7 :: replaceKeyPackagesV7 lusr c (fmap toList -> mSuites) upload = do assertMLSEnabled suites <- validateCipherSuites mSuites upload - lift $ wrapClient (Data.deleteAllKeyPackages (tUnqualified lusr) c suites) + lift $ liftSem (Mls.deleteAllMlsKeyPackages (tUnqualified lusr) c (toList suites)) uploadKeyPackages lusr c upload diff --git a/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs b/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs index 45173c9eb66..62d5bd2b8cd 100644 --- a/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs +++ b/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs @@ -46,6 +46,7 @@ import Wire.API.MLS.Validation import Wire.API.MLS.Validation.Error (toText) import Wire.ClientStore (ClientStore) import Wire.ClientStore qualified as ClientStore +import Wire.MlsKeyPackageSubsystem qualified as Mls validateUploadedKeyPackage :: (Member ClientStore r) => @@ -91,14 +92,7 @@ validateLifetime lt = do validateLifetime' now mMaxLifetime lt validateLifetime' :: POSIXTime -> Maybe NominalDiffTime -> Lifetime -> Either Text () -validateLifetime' now mMaxLifetime lt = do - when (tsPOSIX (ltNotBefore lt) > now) $ - Left "Key package not_before date is in the future" - when (tsPOSIX (ltNotAfter lt) <= now) $ - Left "Key package is expired" - for_ mMaxLifetime $ \maxLifetime -> - when (tsPOSIX (ltNotAfter lt) > now + maxLifetime) $ - Left "Key package expiration time is too far in the future" +validateLifetime' = Mls.validateKeyPackageLifetime mlsProtocolErrorFromValidationError :: ValidationError -> Handler r a mlsProtocolErrorFromValidationError InvalidLeafNodeSignature = throwStd (errorToWai @E.MLSInvalidLeafNodeSignature) diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index a73319359d5..276b0448ca6 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -180,6 +180,7 @@ import Wire.GalleyAPIAccess qualified as GalleyAPIAccess import Wire.HashPassword (HashPassword) import Wire.IndexedUserStore (IndexedUserStore) import Wire.InvitationStore +import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem) import Wire.NotificationSubsystem import Wire.PasswordResetCodeStore (PasswordResetCodeStore) import Wire.PropertySubsystem @@ -418,6 +419,7 @@ servantSitemap :: Member TeamSubsystem r, Member AppSubsystem r, Member ClientStore r, + Member MlsKeyPackageSubsystem r, Member ClientSubsystem r, Member (Error FederationError) r, Member BackendNotificationQueueAccess r, diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index b316bfd3d04..b16e5a0da85 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -113,6 +113,10 @@ import Wire.IndexedUserStore.ElasticSearch import Wire.InvitationStore (InvitationStore) import Wire.InvitationStore.Cassandra (interpretInvitationStoreToCassandra) import Wire.MigrationLock +import Wire.MlsKeyPackageStore (MlsKeyPackageStore) +import Wire.MlsKeyPackageStore.Cassandra (interpretMlsKeyPackageStoreToCassandra) +import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem) +import Wire.MlsKeyPackageSubsystem.Interpreter (interpretMlsKeyPackageSubsystem) import Wire.NotificationSubsystem import Wire.NotificationSubsystem.Interpreter (defaultNotificationSubsystemConfig, runNotificationSubsystemGundeck) import Wire.ParseException @@ -208,6 +212,8 @@ type BrigLowerLevelEffects = BackgroundJobPublisher, RateLimit, UserKeyStore, + MlsKeyPackageSubsystem, + MlsKeyPackageStore, UserStore, UserGroupStore, DomainRegistrationStore, @@ -487,6 +493,8 @@ runBrigToIO e (AppT ma) = do . domainRegistrationStore . interpretUserGroupStoreToPostgres . userStoreInterpreter + . interpretMlsKeyPackageStoreToCassandra e.casClient + . interpretMlsKeyPackageSubsystem e.settings.keyPackageMaximumLifetime e.keyPackageLocalLock . interpretUserKeyStoreCassandra e.casClient . interpretRateLimit e.rateLimitEnv . interpretBackgroundJobPublisherRabbitMQ e.requestId e.amqpJobsPublisherChannel diff --git a/services/brig/src/Brig/Data/MLS/KeyPackage.hs b/services/brig/src/Brig/Data/MLS/KeyPackage.hs deleted file mode 100644 index 20f95a40afa..00000000000 --- a/services/brig/src/Brig/Data/MLS/KeyPackage.hs +++ /dev/null @@ -1,169 +0,0 @@ --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2022 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Brig.Data.MLS.KeyPackage - ( insertKeyPackages, - claimKeyPackage, - countKeyPackages, - deleteKeyPackages, - deleteAllKeyPackages, - ) -where - -import Brig.API.MLS.KeyPackages.Validation -import Brig.App -import Brig.Options -import Cassandra -import Control.Arrow -import Control.Error -import Control.Monad.Random (randomRIO) -import Data.Functor -import Data.Id -import Data.Qualified -import Data.Time.Clock -import Data.Time.Clock.POSIX -import Imports -import UnliftIO.Async -import Wire.API.MLS.CipherSuite -import Wire.API.MLS.KeyPackage -import Wire.API.MLS.LeafNode -import Wire.API.MLS.Serialisation - -insertKeyPackages :: - (MonadClient m) => - UserId -> - ClientId -> - [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> - m () -insertKeyPackages uid cid kps = retry x5 . batch $ do - setType BatchLogged - setConsistency LocalQuorum - for_ kps $ \(ref, suite, kp) -> do - addPrepQuery q (uid, cid, suite, kp, ref) - where - q :: PrepQuery W (UserId, ClientId, CipherSuiteTag, KeyPackageData, KeyPackageRef) () - q = "INSERT INTO mls_key_packages (user, client, cipher_suite, data, ref) VALUES (?, ?, ?, ?, ?)" - -claimKeyPackage :: - ( MonadReader Env m, - MonadUnliftIO m, - MonadClient m - ) => - Local UserId -> - ClientId -> - CipherSuiteTag -> - MaybeT m (KeyPackageRef, KeyPackageData) -claimKeyPackage u c suite = do - -- FUTUREWORK: investigate better locking strategies - lock <- lift $ asks (.keyPackageLocalLock) - -- get a random key package and delete it - (ref, kpd) <- MaybeT . withMVar lock . const $ do - kps <- getNonClaimedKeyPackages u c suite - mk <- liftIO (pick kps) - for mk $ \(ref, kpd) -> do - retry x5 $ write delete1Query (params LocalQuorum (tUnqualified u, c, suite, ref)) - pure (ref, kpd) - pure (ref, kpd) - where - delete1Query :: PrepQuery W (UserId, ClientId, CipherSuiteTag, KeyPackageRef) () - delete1Query = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ? AND ref = ?" - --- | Fetch all unclaimed non-expired key packages for a given client and delete --- from the database those that have expired. -getNonClaimedKeyPackages :: - ( MonadReader Env m, - MonadClient m - ) => - Local UserId -> - ClientId -> - CipherSuiteTag -> - m [(KeyPackageRef, KeyPackageData)] -getNonClaimedKeyPackages u c suite = do - kps <- retry x1 $ query lookupQuery (params LocalQuorum (tUnqualified u, c, suite)) - let decodedKps = foldMap (keepDecoded . (decodeKp &&& id)) kps - - now <- liftIO getPOSIXTime - mMaxLifetime <- asks (.settings.keyPackageMaximumLifetime) - - let (kpsExpired, kpsNonExpired) = - partition (hasExpired now mMaxLifetime) decodedKps - -- delete expired key packages - deleteKeyPackages (tUnqualified u) c suite (map (\(_, (ref, _)) -> ref) kpsExpired) - pure $ fmap snd kpsNonExpired - where - lookupQuery :: PrepQuery R (UserId, ClientId, CipherSuiteTag) (KeyPackageRef, KeyPackageData) - lookupQuery = "SELECT ref, data FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ?" - - decodeKp :: (a, KeyPackageData) -> Maybe KeyPackage - decodeKp = hush . decodeMLS' . kpData . snd - - keepDecoded :: (Maybe a, b) -> [(a, b)] - keepDecoded (Nothing, _) = [] - keepDecoded (Just v, w) = [(v, w)] - - hasExpired :: POSIXTime -> Maybe NominalDiffTime -> (KeyPackage, a) -> Bool - hasExpired now mMaxLifetime (kp, _) = - case kp.leafNode.source of - LeafNodeSourceKeyPackage lt -> - either (const True) (const False) . validateLifetime' now mMaxLifetime $ lt - _ -> True -- the assumption is the key package is valid and has the - -- required extensions so we return 'True' - -countKeyPackages :: - ( MonadReader Env m, - MonadClient m - ) => - Local UserId -> - ClientId -> - CipherSuiteTag -> - m Int64 -countKeyPackages u c suite = fromIntegral . length <$> getNonClaimedKeyPackages u c suite - -deleteKeyPackages :: (MonadClient m) => UserId -> ClientId -> CipherSuiteTag -> [KeyPackageRef] -> m () -deleteKeyPackages u c suite refs = - retry x5 $ - write - deleteQuery - (params LocalQuorum (u, c, suite, refs)) - where - deleteQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag, [KeyPackageRef]) () - deleteQuery = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ? AND ref in ?" - -deleteAllKeyPackages :: - (MonadClient m, MonadUnliftIO m, Foldable f) => - UserId -> - ClientId -> - f CipherSuiteTag -> - m () -deleteAllKeyPackages u c suites = - pooledForConcurrentlyN_ 16 suites $ \suite -> - retry x5 $ - write - deleteQuery - (params LocalQuorum (u, c, suite)) - where - deleteQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag) () - deleteQuery = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ?" - --------------------------------------------------------------------------------- --- Utilities - -pick :: [a] -> IO (Maybe a) -pick [] = pure Nothing -pick xs = do - i <- randomRIO (0, length xs - 1) - pure (atMay xs i) From 8c24463647a1ee969a048d6bd73a2d1c8969a56c Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Fri, 24 Jul 2026 16:50:44 +0200 Subject: [PATCH 030/113] WPB-26101 add contact status enrichment to `POST /list-users` (#5371) --- changelog.d/2-features/WPB-26101 | 1 + integration/test/API/Brig.hs | 11 ++++ integration/test/Test/Teams.hs | 53 ++++++++++++++++- .../src/Wire/API/Routes/Public/Brig.hs | 1 + libs/wire-api/src/Wire/API/User.hs | 32 +++++++++- .../API/Golden/Generated/UserProfile_user.hs | 6 +- .../Wire/API/Golden/Manual/ListUsersById.hs | 6 +- .../testObject_ListUsersById_user_2.json | 3 + .../testObject_ListUsersById_user_3.json | 3 + libs/wire-api/test/unit/Test/Wire/API/User.hs | 3 +- libs/wire-subsystems/src/Wire/ClientStore.hs | 1 + .../src/Wire/ClientStore/Cassandra.hs | 13 ++++ .../src/Wire/MlsKeyPackageStore.hs | 2 + .../src/Wire/MlsKeyPackageStore/Cassandra.hs | 21 ++++++- .../src/Wire/MlsKeyPackageSubsystem.hs | 2 + .../MlsKeyPackageSubsystem/Interpreter.hs | 36 +++++++++++ .../wire-subsystems/src/Wire/UserSubsystem.hs | 3 + .../src/Wire/UserSubsystem/Interpreter.hs | 58 ++++++++++++++++++ .../test/unit/Wire/MiniBackend.hs | 8 +++ .../unit/Wire/MockInterpreters/ClientStore.hs | 7 +++ .../Wire/MockInterpreters/UserSubsystem.hs | 1 + services/brig/src/Brig/API/Public.hs | 59 ++++++++++++++++++- .../brig/test/integration/API/User/Account.hs | 3 +- services/galley/test/integration/API/Util.hs | 3 +- 24 files changed, 322 insertions(+), 14 deletions(-) create mode 100644 changelog.d/2-features/WPB-26101 diff --git a/changelog.d/2-features/WPB-26101 b/changelog.d/2-features/WPB-26101 new file mode 100644 index 00000000000..03d7aede22f --- /dev/null +++ b/changelog.d/2-features/WPB-26101 @@ -0,0 +1 @@ +Add user contact-status enrichment to user listings based on available Proteus and MLS contact methods. diff --git a/integration/test/API/Brig.hs b/integration/test/API/Brig.hs index c3e0decf671..7614567b84f 100644 --- a/integration/test/API/Brig.hs +++ b/integration/test/API/Brig.hs @@ -263,6 +263,17 @@ listUsers usr qualifiedUserIds = do req <- baseRequest usr Brig Versioned $ joinHttpPath ["list-users"] submit "POST" (req & addJSONObject ["qualified_ids" .= qUsers]) +listUsersWithContactStatus :: (HasCallStack, MakesValue user, MakesValue qualifiedUserIds) => user -> [qualifiedUserIds] -> App Response +listUsersWithContactStatus usr qualifiedUserIds = do + qUsers <- mapM objQidObject qualifiedUserIds + req <- baseRequest usr Brig Versioned $ joinHttpPath ["list-users"] + submit + "POST" + ( req + & addQueryParams [("include-contact-status", "true")] + & addJSONObject ["qualified_ids" .= qUsers] + ) + data SearchContactsCfg = SearchContactsCfg { user :: Value, searchTerm :: String, diff --git a/integration/test/Test/Teams.hs b/integration/test/Test/Teams.hs index 30ccc7dd8fb..1553a7da096 100644 --- a/integration/test/Test/Teams.hs +++ b/integration/test/Test/Teams.hs @@ -21,7 +21,7 @@ module Test.Teams where import API.Brig import qualified API.BrigInternal as I import API.Common -import API.Galley (deleteTeamMember, getTeam, getTeamMembers, getTeamMembersCsv, getTeamNotifications) +import API.Galley (deleteTeamMember, getTeam, getTeamMembers, getTeamMembersCsv, getTeamNotifications, setTeamFeatureConfig) import API.GalleyInternal (selectTeamMembers) import qualified API.GalleyInternal as I import API.Gundeck @@ -34,6 +34,7 @@ import qualified Data.Map as Map import qualified Data.Set as Set import Data.Time.Clock import Data.Time.Format +import MLS.Util (createMLSClient, uploadNewKeyPackage) import Notifications import SetupHelpers import Testlib.JSON @@ -501,6 +502,56 @@ testListUsersEmailVisibility = do returnedEmails <- for returnedUsers ((%. "email") >=> asString) returnedEmails `shouldMatchSet` memEmails +testListUsersContactStatus :: (HasCallStack) => App () +testListUsersContactStatus = do + (owner, tid, proteusUser : mlsUser : _) <- createTeam OwnDomain 3 + noClientUser <- randomUser OwnDomain def + addClient proteusUser def >>= assertStatus 201 + mlsClient <- createMLSClient def mlsUser + void $ uploadNewKeyPackage def mlsClient + putUserSupportedProtocols mlsUser ["mls"] >>= assertSuccess + setTeamFeatureConfig + owner + tid + "mls" + ( object + [ "status" .= ("enabled" :: String), + "config" + .= object + [ "protocolToggleUsers" .= ([] :: [String]), + "defaultProtocol" .= ("mls" :: String), + "supportedProtocols" .= (["proteus", "mls"] :: [String]), + "allowedCipherSuites" .= ([2] :: [Int]), + "defaultCipherSuite" .= (2 :: Int) + ] + ] + ) + >>= assertSuccess + + let users = [noClientUser, proteusUser, mlsUser] + userIds <- for users objQidObject + + listUsers owner userIds `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + profiles <- resp.json %. "found" >>= asList + for_ profiles $ \profile -> + lookupField profile "contact_status" `shouldMatch` (Nothing :: Maybe Value) + + listUsersWithContactStatus owner userIds `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + profiles <- resp.json %. "found" >>= asList + assertContactStatus profiles noClientUser "non-contactable" + assertContactStatus profiles proteusUser "contactable" + assertContactStatus profiles mlsUser "contactable" + where + assertContactStatus :: (HasCallStack) => [Value] -> Value -> String -> App () + assertContactStatus profiles user expected = do + uid <- user %. "id" + profile <- findM (fmap (== Just uid) . flip lookupField "id") profiles + case profile of + Nothing -> assertFailure "list-users did not return the requested user" + Just p -> p %. "contact_status.state" `shouldMatch` expected + testGetTeamsInvitationInfo :: (HasCallStack) => App () testGetTeamsInvitationInfo = do (owner, tid, _) <- createTeam OwnDomain 1 diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs index 8fe7484156b..cf7012ccba7 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -237,6 +237,7 @@ type UserAPI = :> ZUser :> From 'V4 :> "list-users" + :> QueryParam' [Optional, Strict, Description "Include whether each local user can currently be contacted"] "include-contact-status" Bool :> ReqBody '[JSON] ListUsersQuery :> Post '[JSON] ListUsersById ) diff --git a/libs/wire-api/src/Wire/API/User.hs b/libs/wire-api/src/Wire/API/User.hs index 6e7bd0ba119..161040a456e 100644 --- a/libs/wire-api/src/Wire/API/User.hs +++ b/libs/wire-api/src/Wire/API/User.hs @@ -31,6 +31,8 @@ module Wire.API.User UserSet (..), -- Profiles UserProfile (..), + ContactStatus (..), + ContactStatusState (..), SelfProfile (..), -- User (should not be here) User (..), @@ -545,7 +547,8 @@ data UserProfile = UserProfile profileSupportedProtocols :: Set BaseProtocolTag, profileType :: UserType, profileApp :: Maybe AppInfo, - profileSearchable :: Bool + profileSearchable :: Bool, + profileContactStatus :: Maybe ContactStatus } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform UserProfile) @@ -589,6 +592,30 @@ userProfileObjectSchema = <*> profileType .= fmap (fromMaybe UserTypeRegular) (optField "type" schema) <*> profileApp .= maybe_ (optField "app" schema) <*> profileSearchable .= fmap (fromMaybe True) (optField "searchable" schema) + <*> profileContactStatus .= maybe_ (optField "contact_status" schema) + +data ContactStatusState + = Contactable + | NonContactable + deriving stock (Eq, Ord, Show, Generic) + deriving (Arbitrary) via (GenericUniform ContactStatusState) + deriving (FromJSON, ToJSON, S.ToSchema) via (Schema ContactStatusState) + +instance ToSchema ContactStatusState where + schema = + enum @Text $ + element "contactable" Contactable + <> element "non-contactable" NonContactable + +data ContactStatus = ContactStatus + { contactStatusState :: ContactStatusState + } + deriving stock (Eq, Ord, Show, Generic) + deriving (Arbitrary) via (GenericUniform ContactStatus) + deriving (FromJSON, ToJSON, S.ToSchema) via (Schema ContactStatus) + +instance ToSchema ContactStatus where + schema = object $ ContactStatus <$> contactStatusState .= field "state" schema -------------------------------------------------------------------------------- -- SelfProfile @@ -777,7 +804,8 @@ mkUserProfileWithEmail memail u mba legalHoldStatus = profileSupportedProtocols = userSupportedProtocols u, profileType = u.userType, profileApp = mba, - profileSearchable = userSearchable u + profileSearchable = userSearchable u, + profileContactStatus = Nothing } mkUserProfile :: EmailVisibilityConfigWithViewer -> User -> Maybe AppInfo -> UserLegalHoldStatus -> UserProfile diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UserProfile_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UserProfile_user.hs index 6633d2a9e42..99a69b9e932 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UserProfile_user.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UserProfile_user.hs @@ -54,7 +54,8 @@ testObject_UserProfile_user_1 = profileSupportedProtocols = defSupportedProtocols, profileType = UserTypeRegular, profileApp = Nothing, - profileSearchable = True + profileSearchable = True, + profileContactStatus = Nothing } testObject_UserProfile_user_2 :: UserProfile @@ -92,5 +93,6 @@ testObject_UserProfile_user_2 = { category = Category "other", description = unsafeRange "bloob" }, - profileSearchable = True + profileSearchable = True, + profileContactStatus = Nothing } diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ListUsersById.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ListUsersById.hs index 34e63641db6..02b3d1ae302 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ListUsersById.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ListUsersById.hs @@ -56,7 +56,8 @@ profile1 = profileSupportedProtocols = defSupportedProtocols, profileType = UserTypeRegular, profileApp = Nothing, - profileSearchable = True + profileSearchable = True, + profileContactStatus = Just (ContactStatus NonContactable) } profile2 = UserProfile @@ -81,7 +82,8 @@ profile2 = { category = Category "other", description = unsafeRange "bloob" }, - profileSearchable = True + profileSearchable = True, + profileContactStatus = Nothing } testObject_ListUsersById_user_1 :: ListUsersById diff --git a/libs/wire-api/test/golden/testObject_ListUsersById_user_2.json b/libs/wire-api/test/golden/testObject_ListUsersById_user_2.json index 77b0f024dc8..dc3639cc1b8 100644 --- a/libs/wire-api/test/golden/testObject_ListUsersById_user_2.json +++ b/libs/wire-api/test/golden/testObject_ListUsersById_user_2.json @@ -3,6 +3,9 @@ { "accent_id": 0, "assets": [], + "contact_status": { + "state": "non-contactable" + }, "id": "4f201a43-935e-4e19-8fe0-0a878d3d6e74", "legalhold_status": "disabled", "name": "user1", diff --git a/libs/wire-api/test/golden/testObject_ListUsersById_user_3.json b/libs/wire-api/test/golden/testObject_ListUsersById_user_3.json index 134ec75cf61..a708c79c97e 100644 --- a/libs/wire-api/test/golden/testObject_ListUsersById_user_3.json +++ b/libs/wire-api/test/golden/testObject_ListUsersById_user_3.json @@ -9,6 +9,9 @@ { "accent_id": 0, "assets": [], + "contact_status": { + "state": "non-contactable" + }, "id": "4f201a43-935e-4e19-8fe0-0a878d3d6e74", "legalhold_status": "disabled", "name": "user1", diff --git a/libs/wire-api/test/unit/Test/Wire/API/User.hs b/libs/wire-api/test/unit/Test/Wire/API/User.hs index ce4db2890ea..4285ecf5285 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/User.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/User.hs @@ -135,7 +135,8 @@ testUserProfile = do profileSupportedProtocols = defSupportedProtocols, profileType = UserTypeRegular, profileApp = Nothing, - profileSearchable = True + profileSearchable = True, + profileContactStatus = Nothing } let profileJSONAsText = show $ Aeson.encode userProfile let msg = "toJSON encoding must not convert Nothing to null, but instead omit those json fields for backwards compatibility. UserProfileJSON:" <> profileJSONAsText diff --git a/libs/wire-subsystems/src/Wire/ClientStore.hs b/libs/wire-subsystems/src/Wire/ClientStore.hs index 61cc21e3344..844ecb068d9 100644 --- a/libs/wire-subsystems/src/Wire/ClientStore.hs +++ b/libs/wire-subsystems/src/Wire/ClientStore.hs @@ -29,6 +29,7 @@ data ClientStore m a where LookupClientsBulk :: [UserId] -> ClientStore m (UserMap (Set Client)) LookupPubClientsBulk :: [UserId] -> ClientStore m (UserMap (Set PubClient)) LookupPrekeyIds :: UserId -> ClientId -> ClientStore m [PrekeyId] + LookupPrekeyPresenceBulk :: [(UserId, ClientId)] -> ClientStore m (Set (UserId, ClientId)) GetActivityTimestamps :: UserId -> ClientStore m [Maybe UTCTime] -- Proteus UpdatePrekeys :: UserId -> ClientId -> [UncheckedPrekeyBundle] -> ClientStore m () diff --git a/libs/wire-subsystems/src/Wire/ClientStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ClientStore/Cassandra.hs index 57143288b43..6e962abb96a 100644 --- a/libs/wire-subsystems/src/Wire/ClientStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/ClientStore/Cassandra.hs @@ -62,6 +62,7 @@ interpretClientStoreCassandra env = LookupClientsBulk uids -> runCasClient $ lookupClientsBulkImpl uids LookupPubClientsBulk uids -> runCasClient $ lookupPubClientsBulkImpl uids LookupPrekeyIds uid cid -> runCasClient $ lookupPrekeyIdsImpl uid cid + LookupPrekeyPresenceBulk pairs -> runCasClient $ lookupPrekeyPresenceBulkImpl pairs GetActivityTimestamps uid -> runCasClient $ getActivityTimestampsImpl uid -- Proteus UpdatePrekeys uid cid prekeys -> runCasClient $ updatePrekeysImpl uid cid prekeys @@ -142,6 +143,15 @@ lookupPrekeyIdsImpl u c = map runIdentity <$> retry x1 (query selectPrekeyIds (params LocalQuorum (u, c))) +lookupPrekeyPresenceBulkImpl :: (MonadClient m, MonadUnliftIO m) => [(UserId, ClientId)] -> m (Set.Set (UserId, ClientId)) +lookupPrekeyPresenceBulkImpl pairs = + Set.fromList . map fst . filter snd + <$> pooledMapConcurrentlyN 16 (\pair@(u, c) -> (pair,) <$> lookupPrekeyPresenceImpl u c) pairs + +lookupPrekeyPresenceImpl :: (MonadClient m) => UserId -> ClientId -> m Bool +lookupPrekeyPresenceImpl u c = + isJust <$> retry x1 (query1 selectPrekeyPresence (params LocalQuorum (u, c))) + getActivityTimestampsImpl :: (MonadClient m) => UserId -> m [Maybe UTCTime] getActivityTimestampsImpl uid = do runIdentity <$$> retry x1 (query q (params LocalQuorum (Identity uid))) @@ -319,6 +329,9 @@ userPrekeys = "SELECT key, data FROM prekeys where user = ? and client = ?" selectPrekeyIds :: PrepQuery R (UserId, ClientId) (Identity PrekeyId) selectPrekeyIds = "SELECT key FROM prekeys where user = ? and client = ?" +selectPrekeyPresence :: PrepQuery R (UserId, ClientId) (Identity PrekeyId) +selectPrekeyPresence = "SELECT key FROM prekeys where user = ? and client = ? LIMIT 1" + removePrekey :: PrepQuery W (UserId, ClientId, PrekeyId) () removePrekey = "DELETE FROM prekeys where user = ? and client = ? and key = ?" diff --git a/libs/wire-subsystems/src/Wire/MlsKeyPackageStore.hs b/libs/wire-subsystems/src/Wire/MlsKeyPackageStore.hs index a0bba004beb..644c5f6406f 100644 --- a/libs/wire-subsystems/src/Wire/MlsKeyPackageStore.hs +++ b/libs/wire-subsystems/src/Wire/MlsKeyPackageStore.hs @@ -20,6 +20,7 @@ module Wire.MlsKeyPackageStore where import Data.Id +import Data.Map qualified as Map import Polysemy import Wire.API.MLS.CipherSuite import Wire.API.MLS.KeyPackage @@ -27,6 +28,7 @@ import Wire.API.MLS.KeyPackage data MlsKeyPackageStore m a where InsertKeyPackages :: UserId -> ClientId -> [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> MlsKeyPackageStore m () LookupKeyPackages :: UserId -> ClientId -> CipherSuiteTag -> MlsKeyPackageStore m [(KeyPackageRef, KeyPackageData)] + LookupKeyPackagesBulk :: [(UserId, ClientId, CipherSuiteTag)] -> MlsKeyPackageStore m (Map.Map (UserId, ClientId, CipherSuiteTag) [(KeyPackageRef, KeyPackageData)]) DeleteKeyPackages :: UserId -> ClientId -> CipherSuiteTag -> [KeyPackageRef] -> MlsKeyPackageStore m () DeleteAllKeyPackages :: UserId -> ClientId -> [CipherSuiteTag] -> MlsKeyPackageStore m () DeleteKeyPackage :: UserId -> ClientId -> CipherSuiteTag -> KeyPackageRef -> MlsKeyPackageStore m () diff --git a/libs/wire-subsystems/src/Wire/MlsKeyPackageStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/MlsKeyPackageStore/Cassandra.hs index 25e0800147b..2ec1aa2f73b 100644 --- a/libs/wire-subsystems/src/Wire/MlsKeyPackageStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/MlsKeyPackageStore/Cassandra.hs @@ -19,10 +19,13 @@ module Wire.MlsKeyPackageStore.Cassandra (interpretMlsKeyPackageStoreToCassandra import Cassandra as C hiding (Client) import Data.Id +import Data.List.Extra (chunksOf) +import Data.Map qualified as Map +import Data.Set qualified as Set import Imports import Polysemy import Polysemy.Embed -import UnliftIO.Async (pooledForConcurrentlyN_) +import UnliftIO.Async (pooledForConcurrentlyN_, pooledMapConcurrentlyN) import Wire.API.MLS.CipherSuite import Wire.API.MLS.KeyPackage (KeyPackageData, KeyPackageRef) import Wire.MlsKeyPackageStore (MlsKeyPackageStore (..)) @@ -33,6 +36,7 @@ interpretMlsKeyPackageStoreToCassandra cas = runEmbedded (runClient cas) . \case InsertKeyPackages u c ps -> embed $ insertKeyPackages u c ps LookupKeyPackages u c s -> embed $ lookupKeyPackages u c s + LookupKeyPackagesBulk requests -> embed $ lookupKeyPackagesBulk requests DeleteKeyPackages u c s rs -> embed $ deleteKeyPackages u c s rs DeleteAllKeyPackages u c ss -> embed $ deleteAllKeyPackages u c ss DeleteKeyPackage u c s r -> embed $ deleteKeyPackage u c s r @@ -46,6 +50,18 @@ insertKeyPackages u c ps = retry x5 . batch $ do lookupKeyPackages :: (MonadClient m) => UserId -> ClientId -> CipherSuiteTag -> m [(KeyPackageRef, KeyPackageData)] lookupKeyPackages u c s = retry x1 $ query lookupQuery (params LocalQuorum (u, c, s)) +lookupKeyPackagesBulk :: (MonadClient m, MonadUnliftIO m) => [(UserId, ClientId, CipherSuiteTag)] -> m (Map (UserId, ClientId, CipherSuiteTag) [(KeyPackageRef, KeyPackageData)]) +lookupKeyPackagesBulk requests = + Map.fromListWith (<>) . concat + <$> pooledMapConcurrentlyN 16 lookupClient (Map.toList grouped) + where + grouped = Map.fromListWith Set.union [((u, c), Set.singleton s) | (u, c, s) <- requests] + + lookupClient ((u, c), suites) = + fmap concat . for (chunksOf 8 (Set.toList suites)) $ \suiteChunk -> do + rows <- retry x1 $ query lookupBulkQuery (params LocalQuorum (u, c, suiteChunk)) + pure [((u, c, suite), [(ref, packageData)]) | (suite, ref, packageData) <- rows] + deleteKeyPackages :: (MonadClient m) => UserId -> ClientId -> CipherSuiteTag -> [KeyPackageRef] -> m () deleteKeyPackages u c s rs = retry x5 $ write deleteQuery (params LocalQuorum (u, c, s, rs)) @@ -62,6 +78,9 @@ insertQuery = "INSERT INTO mls_key_packages (user, client, cipher_suite, data, r lookupQuery :: PrepQuery R (UserId, ClientId, CipherSuiteTag) (KeyPackageRef, KeyPackageData) lookupQuery = "SELECT ref, data FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ?" +lookupBulkQuery :: PrepQuery R (UserId, ClientId, [CipherSuiteTag]) (CipherSuiteTag, KeyPackageRef, KeyPackageData) +lookupBulkQuery = "SELECT cipher_suite, ref, data FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite IN ?" + deleteQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag, [KeyPackageRef]) () deleteQuery = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ? AND ref IN ?" diff --git a/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem.hs b/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem.hs index 6c5b920f2bb..1f18b53d372 100644 --- a/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem.hs @@ -31,6 +31,8 @@ import Wire.API.MLS.Lifetime data MlsKeyPackageSubsystem m a where InsertMlsKeyPackages :: UserId -> ClientId -> [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> MlsKeyPackageSubsystem m () ClaimMlsKeyPackage :: UserId -> ClientId -> CipherSuiteTag -> MlsKeyPackageSubsystem m (Maybe (KeyPackageRef, KeyPackageData)) + HasMlsKeyPackages :: UserId -> ClientId -> CipherSuiteTag -> MlsKeyPackageSubsystem m Bool + HasMlsKeyPackagesBulk :: [(UserId, ClientId, CipherSuiteTag)] -> MlsKeyPackageSubsystem m (Set (UserId, ClientId, CipherSuiteTag)) CountMlsKeyPackages :: UserId -> ClientId -> CipherSuiteTag -> MlsKeyPackageSubsystem m Int64 DeleteMlsKeyPackages :: UserId -> ClientId -> CipherSuiteTag -> [KeyPackageRef] -> MlsKeyPackageSubsystem m () DeleteAllMlsKeyPackages :: UserId -> ClientId -> [CipherSuiteTag] -> MlsKeyPackageSubsystem m () diff --git a/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem/Interpreter.hs index d0b58564be4..4e9ca717600 100644 --- a/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem/Interpreter.hs @@ -21,6 +21,8 @@ import Control.Concurrent qualified as C import Control.Error (atMay) import Control.Monad.Random (randomRIO) import Data.Id +import Data.Map qualified as Map +import Data.Set qualified as Set import Data.Time.Clock (NominalDiffTime) import Data.Time.Clock.POSIX import Imports @@ -37,6 +39,8 @@ interpretMlsKeyPackageSubsystem :: (Member Store.MlsKeyPackageStore r, Member (E interpretMlsKeyPackageSubsystem configuredLifetime lock = interpret $ \case InsertMlsKeyPackages u c ps -> insertMlsKeyPackages u c ps ClaimMlsKeyPackage u c s -> claimMlsKeyPackage configuredLifetime lock u c s + HasMlsKeyPackages u c s -> hasMlsKeyPackages configuredLifetime u c s + HasMlsKeyPackagesBulk requests -> hasMlsKeyPackagesBulk configuredLifetime requests CountMlsKeyPackages u c s -> countMlsKeyPackages configuredLifetime u c s DeleteMlsKeyPackages u c s rs -> deleteMlsKeyPackages u c s rs DeleteAllMlsKeyPackages u c ss -> deleteAllMlsKeyPackages u c ss @@ -44,6 +48,38 @@ interpretMlsKeyPackageSubsystem configuredLifetime lock = interpret $ \case insertMlsKeyPackages :: (Member Store.MlsKeyPackageStore r) => UserId -> ClientId -> [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> Sem r () insertMlsKeyPackages u c ps = Store.insertKeyPackages u c ps +hasMlsKeyPackages :: (Member Store.MlsKeyPackageStore r, Member (Embed IO) r) => Maybe NominalDiffTime -> UserId -> ClientId -> CipherSuiteTag -> Sem r Bool +hasMlsKeyPackages maxLifetime u c s = do + rows <- Store.lookupKeyPackages u c s + now <- embed getPOSIXTime + pure . any (isUsable now maxLifetime) $ mapMaybe decode rows + where + decode :: (KeyPackageRef, KeyPackageData) -> Maybe KeyPackage + decode (_, packageData) = do + package <- either (const Nothing) Just (decodeMLS' (kpData packageData) :: Either Text (RawMLS KeyPackage)) + pure package.value + + isUsable now configuredLifetime package = + case package.leafNode.source of + LeafNodeSourceKeyPackage lifetime -> isRight (validateKeyPackageLifetime now configuredLifetime lifetime) + _ -> False + +hasMlsKeyPackagesBulk :: (Member Store.MlsKeyPackageStore r, Member (Embed IO) r) => Maybe NominalDiffTime -> [(UserId, ClientId, CipherSuiteTag)] -> Sem r (Set (UserId, ClientId, CipherSuiteTag)) +hasMlsKeyPackagesBulk maxLifetime requests = do + rows <- Store.lookupKeyPackagesBulk requests + now <- embed getPOSIXTime + pure . Set.fromList . map fst . filter (any (isUsable now maxLifetime) . mapMaybe decode . snd) $ Map.toList rows + where + decode :: (KeyPackageRef, KeyPackageData) -> Maybe KeyPackage + decode (_, packageData) = do + package <- either (const Nothing) Just (decodeMLS' (kpData packageData) :: Either Text (RawMLS KeyPackage)) + pure package.value + + isUsable now configuredLifetime package = + case package.leafNode.source of + LeafNodeSourceKeyPackage lifetime -> isRight (validateKeyPackageLifetime now configuredLifetime lifetime) + _ -> False + claimMlsKeyPackage :: (Member Store.MlsKeyPackageStore r, Member (Embed IO) r, Member Resource r) => Maybe NominalDiffTime -> diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem.hs b/libs/wire-subsystems/src/Wire/UserSubsystem.hs index 37358d294da..0f5f428ae73 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem.hs @@ -43,6 +43,7 @@ import SAML2.WebSSO qualified as SAML import Text.Email.Parser import Wire.API.EnterpriseLogin import Wire.API.Federation.Error +import Wire.API.MLS.CipherSuite (CipherSuiteTag) import Wire.API.Routes.Internal.Brig (GetBy (..), getByNoFilters) import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti (TeamStatus) import Wire.API.Team.Export (TeamExportUser) @@ -50,6 +51,7 @@ import Wire.API.Team.Feature import Wire.API.Team.Member (IsPerm (..), TeamMember) import Wire.API.User import Wire.API.User.Activation +import Wire.API.User.Client (Client) import Wire.API.User.IdentityProvider hiding (domain, team) import Wire.API.User.Search import Wire.ActivationCodeStore @@ -167,6 +169,7 @@ data UserSubsystem m a where Maybe (Range 1 500 Int32) -> Maybe [UserTypeFilter] -> UserSubsystem m (SearchResult Contact) + IsUsersContactable :: Map UserId (Set BaseProtocolTag, Set Client) -> Bool -> Set CipherSuiteTag -> UserSubsystem m (Map UserId Bool) BrowseTeam :: UserId -> BrowseTeamFilters -> diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index 018d9d931b9..d66a3d87b40 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -57,6 +57,7 @@ import Wire.API.EnterpriseLogin import Wire.API.Federation.API import Wire.API.Federation.API.Brig qualified as FedBrig import Wire.API.Federation.Error +import Wire.API.MLS.CipherSuite (CipherSuiteTag, csSignatureScheme) import Wire.API.Routes.FederationDomainConfig import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti (TeamStatus (..)) import Wire.API.Team.Collaborator @@ -69,6 +70,7 @@ import Wire.API.Team.Role (Role, defaultRole, permissionsToRole) import Wire.API.Team.SearchVisibility import Wire.API.Team.Size import Wire.API.User as User +import Wire.API.User.Client qualified as UserClient import Wire.API.User.RichInfo import Wire.API.User.Search import Wire.API.UserEvent @@ -78,6 +80,8 @@ import Wire.AppSubsystem import Wire.AppSubsystem.Interpreter import Wire.AuthenticationSubsystem import Wire.BlockListStore as BlockList +import Wire.ClientStore (ClientStore) +import Wire.ClientStore qualified as ClientStore import Wire.ClientSubsystem (ClientSubsystem) import Wire.ClientSubsystem qualified as ClientSubsystem import Wire.DeleteQueue @@ -91,6 +95,8 @@ import Wire.IndexedUserStore (IndexedUserStore) import Wire.IndexedUserStore qualified as IndexedUserStore import Wire.IndexedUserStore.Bulk.ElasticSearch (teamSearchVisibilityInbound) import Wire.InvitationStore +import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem) +import Wire.MlsKeyPackageSubsystem qualified as Mls import Wire.Sem.Concurrency import Wire.Sem.Metrics import Wire.Sem.Metrics qualified as Metrics @@ -115,6 +121,8 @@ runUserSubsystem :: ( Member TeamCollaboratorsSubsystem r, Member AppStore r, Member UserStore r, + Member ClientStore r, + Member MlsKeyPackageSubsystem r, Member UserKeyStore r, Member GalleyAPIAccess r, Member BlockListStore r, @@ -185,6 +193,8 @@ runUserSubsystem authInterpreter appInterpreter clientInterpreter = updateTeamSearchVisibilityInboundImpl status SearchUsers luid query mDomain mMaxResults mTypes -> searchUsersImpl luid query mDomain mMaxResults mTypes + IsUsersContactable users mlsAvailable allowedCipherSuites -> + isUsersContactableImpl users mlsAvailable allowedCipherSuites BrowseTeam uid browseTeamFilters mMaxResults mPagingState -> browseTeamImpl uid browseTeamFilters mMaxResults mPagingState InternalUpdateSearchIndex uid -> @@ -199,6 +209,54 @@ runUserSubsystem authInterpreter appInterpreter clientInterpreter = CheckUserIsAdmin uid -> checkUserIsAdminImpl uid UserSubsystem.SetUserSearchable luid uid searchability -> setUserSearchableImpl luid uid searchability +isUsersContactableImpl :: + forall r. + (Member ClientStore r, Member MlsKeyPackageSubsystem r) => + Map UserId (Set BaseProtocolTag, Set UserClient.Client) -> + Bool -> + Set CipherSuiteTag -> + Sem r (Map UserId Bool) +isUsersContactableImpl users mlsAvailable allowedCipherSuites = do + let prekeyRequests = + [ (uid, client.clientId) + | (uid, (protocols, clients)) <- Map.toList users, + Set.member BaseProtocolProteusTag protocols, + client <- Set.toList clients + ] + mlsRequests = + [ (uid, client.clientId, ciphersuite) + | mlsAvailable, + (uid, (protocols, clients)) <- Map.toList users, + Set.member BaseProtocolMLSTag protocols, + client <- Set.toList clients, + ciphersuite <- Set.toList allowedCipherSuites, + Map.member + (csSignatureScheme ciphersuite) + client.clientMLSPublicKeys + ] + prekeyPresence <- ClientStore.lookupPrekeyPresenceBulk prekeyRequests + mlsPresence <- Mls.hasMlsKeyPackagesBulk mlsRequests + pure $ Map.mapWithKey (isContactable prekeyPresence mlsPresence) users + where + isContactable prekeyPresence mlsPresence uid (protocols, clients) = + let proteusReady = + Set.member BaseProtocolProteusTag protocols + && any (\client -> Set.member (uid, client.clientId) prekeyPresence) clients + mlsReady = + mlsAvailable + && Set.member BaseProtocolMLSTag protocols + && any + ( \client -> + any + ( \ciphersuite -> + Map.member (csSignatureScheme ciphersuite) client.clientMLSPublicKeys + && Set.member (uid, client.clientId, ciphersuite) mlsPresence + ) + allowedCipherSuites + ) + clients + in proteusReady || mlsReady + scimExtId :: StoredUser -> Maybe Text scimExtId su = do m <- su.managedBy diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index 88624c7019f..1aa9f8bdf01 100644 --- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs +++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs @@ -120,6 +120,7 @@ import Wire.HashPassword (HashPassword) import Wire.IndexedUserStore import Wire.InternalEvent hiding (DeleteUser) import Wire.InvitationStore +import Wire.MlsKeyPackageSubsystem import Wire.MockInterpreters import Wire.NotificationSubsystem import Wire.PasswordResetCodeStore @@ -275,6 +276,7 @@ type MiniBackendLowerEffects = GalleyAPIAccess, SparAPIAccess, ClientStore, + MlsKeyPackageSubsystem, InvitationStore, PasswordStore, ActivationCodeStore, @@ -348,6 +350,7 @@ miniBackendLowerEffectsInterpreters mb@(MiniBackendParams {..}) = . inMemoryActivationCodeStoreInterpreter . runInMemoryPasswordStoreInterpreter . inMemoryInvitationStoreInterpreter + . mockMlsKeyPackageSubsystem . runInMemoryClientStoreInterpreter . miniSparAPIAccess . miniGalleyAPIAccess teams galleyConfigs @@ -378,6 +381,11 @@ miniBackendLowerEffectsInterpreters mb@(MiniBackendParams {..}) = mockConversationSubsystem :: forall r'. InterpreterFor ConversationSubsystem r' mockConversationSubsystem = interpretH $ \case _ -> error "Unimplemented ConversationSubsystem operation in mock" + mockMlsKeyPackageSubsystem :: forall r'. InterpreterFor MlsKeyPackageSubsystem r' + mockMlsKeyPackageSubsystem = interpret $ \case + HasMlsKeyPackages {} -> pure False + HasMlsKeyPackagesBulk {} -> pure mempty + _ -> error "Unimplemented MlsKeyPackageSubsystem operation in mock" type StateEffects = '[ State [Push], diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ClientStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ClientStore.hs index 5c6918bfd9f..5ae23b22dde 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ClientStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ClientStore.hs @@ -91,6 +91,13 @@ handleClientStore = \case ] LookupPrekeyIds uid cid -> gets $ maybe [] (map (.prekeyId) . (.prekeys)) . Map.lookup cid . Map.findWithDefault mempty uid . (.byUser) + LookupPrekeyPresenceBulk pairs -> + gets $ \st -> + Set.fromList + [ (uid, cid) + | (uid, cid) <- pairs, + maybe False (not . null . (.prekeys)) (Map.lookup cid (Map.findWithDefault mempty uid st.byUser)) + ] GetActivityTimestamps uid -> gets $ map (.client.clientLastActive) . Map.elems . Map.findWithDefault mempty uid . (.byUser) UpdatePrekeys uid cid prekeys -> diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs index 2904be15ef0..786f733240f 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs @@ -75,6 +75,7 @@ inMemoryUserSubsystemInterpreter = GetAccountsBy (tUnqualified -> GetBy _ _ _ uids []) -> mkUserFromStored testDomain testLocale <$$> UserStore.getUsers uids GetAccountsBy _ -> error "GetAccountsBy: implement on demand (userSubsystemInterpreter)" + IsUsersContactable users _ _ -> pure (False <$ users) UpdateUserProfile {} -> error "UpdateUserProfile: implement on demand (userSubsystemInterpreter)" CheckHandle _ -> error "CheckHandle: implement on demand (userSubsystemInterpreter)" CheckHandles _ _ -> error "CheckHandles: implement on demand (userSubsystemInterpreter)" diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 276b0448ca6..7499ffa051c 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -1,6 +1,7 @@ {-# LANGUAGE PartialTypeSignatures #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-partial-type-signatures #-} -- This file is part of the Wire Server implementation. @@ -33,6 +34,7 @@ import Brig.API.Connection qualified as API import Brig.API.Error import Brig.API.Handler import Brig.API.MLS.KeyPackages +import Brig.API.MLS.Util (isMLSEnabled) import Brig.API.OAuth (oauthAPI) import Brig.API.Public.Swagger import Brig.API.Types @@ -81,6 +83,7 @@ import Data.OpenApi qualified as S import Data.Qualified import Data.Range import Data.Schema () +import Data.Set qualified as Set import Data.Text.Encoding qualified as Text import Data.ZAuth.CryptoSign (CryptoSign) import Data.ZAuth.Token qualified as ZAuth @@ -101,6 +104,7 @@ import Servant.Swagger.UI import System.Logger.Class qualified as Log import Util.Logging (logFunction, logHandle, logTeam, logUser) import Wire.API.Connection qualified as Public +import Wire.API.Conversation.Protocol (ProtocolTag (..)) import Wire.API.EnterpriseLogin import Wire.API.Error import Wire.API.Error.Brig qualified as E @@ -135,6 +139,7 @@ import Wire.API.Routes.Version import Wire.API.SwaggerHelper (cleanupSwagger) import Wire.API.SystemSettings import Wire.API.Team qualified as Public +import Wire.API.Team.Feature qualified as Feature import Wire.API.Team.LegalHold (LegalholdProtectee (..)) import Wire.API.Team.Member (HiddenPerm (..), IsPerm (..), hasPermission) import Wire.API.User (RegisterError (RegisterErrorAllowlistError), UserProfile) @@ -1114,11 +1119,12 @@ listUsersByIdsOrHandlesV3 self q = do -- using a new return type listUsersByIdsOrHandles :: forall r. - (Member UserSubsystem r, Member UserStore r) => + (Member UserSubsystem r, Member UserStore r, Member ClientStore r, Member GalleyAPIAccess r) => UserId -> + Maybe Bool -> Public.ListUsersQuery -> Handler r ListUsersById -listUsersByIdsOrHandles self q = do +listUsersByIdsOrHandles self includeContactStatus q = do lself <- qualifyLocal self (errors, foundUsers) <- case q of Public.ListUsersByIds us -> @@ -1128,7 +1134,11 @@ listUsersByIdsOrHandles self q = do (l, r) <- byIds lself us r' <- Handle.filterHandleResults lself r pure (l, r') - pure $ ListUsersById foundUsers $ fst <$$> nonEmpty errors + foundUsers' <- + if includeContactStatus == Just True + then enrichContactStatus lself foundUsers + else pure foundUsers + pure $ ListUsersById foundUsers' $ fst <$$> nonEmpty errors where byIds :: Local UserId -> @@ -1136,6 +1146,49 @@ listUsersByIdsOrHandles self q = do Handler r ([(Qualified UserId, FederationError)], [Public.UserProfile]) byIds lself uids = lift (liftSem (getUserProfilesWithErrors lself uids)) +enrichContactStatus :: + forall r. + (Member ClientStore r, Member UserSubsystem r, Member GalleyAPIAccess r) => + Local UserId -> + [Public.UserProfile] -> + Handler r [Public.UserProfile] +enrichContactStatus lself profiles = do + let localProfiles = filter ((== tDomain lself) . qDomain . Public.profileQualifiedId) profiles + if null localProfiles + then pure profiles + else do + serverMLS <- isMLSEnabled + (mlsAvailable, allowedCipherSuites) <- + if not serverMLS + then pure (False, Set.empty) + else do + requesterFeatures <- lift . liftSem $ GalleyAPIAccess.getAllTeamFeaturesForUser (Just (tUnqualified lself)) + let mlsConfig = Feature.npProject @Feature.MLSConfig requesterFeatures + pure + ( serverMLS + && mlsConfig.status == Feature.FeatureStatusEnabled + && ProtocolMLSTag `elem` mlsConfig.config.mlsSupportedProtocols, + Set.fromList mlsConfig.config.mlsAllowedCipherSuites + ) + let localUserIds = qUnqualified . Public.profileQualifiedId <$> localProfiles + clients <- lift . liftSem $ ClientStore.lookupClientsBulk localUserIds + let users = + Map.fromList + [ (uid, (profile.profileSupportedProtocols, fromMaybe Set.empty (Map.lookup uid (Public.userMap clients)))) + | profile <- localProfiles, + let uid = qUnqualified profile.profileQualifiedId + ] + contactability <- lift . liftSem $ User.isUsersContactable users mlsAvailable allowedCipherSuites + for profiles $ enrichProfile contactability + where + enrichProfile contactability profile + -- the federated case is not checked, yet + | qDomain profile.profileQualifiedId /= tDomain lself = pure profile + | otherwise = do + let uid = qUnqualified profile.profileQualifiedId + contactable = Map.findWithDefault False uid contactability + pure profile {Public.profileContactStatus = Just (Public.ContactStatus (if contactable then Public.Contactable else Public.NonContactable))} + newtype GetActivationCodeResp = GetActivationCodeResp (Public.ActivationKey, Public.ActivationCode) diff --git a/services/brig/test/integration/API/User/Account.hs b/services/brig/test/integration/API/User/Account.hs index 6348fc1309c..da547287827 100644 --- a/services/brig/test/integration/API/User/Account.hs +++ b/services/brig/test/integration/API/User/Account.hs @@ -807,7 +807,8 @@ testMultipleUsers opts brig = do profileSupportedProtocols = defSupportedProtocols, profileType = UserTypeRegular, profileApp = Nothing, - profileSearchable = True + profileSearchable = True, + profileContactStatus = Nothing } users = [u1, u2, u3] q = ListUsersByIds $ u5 : u4 : map userQualifiedId users diff --git a/services/galley/test/integration/API/Util.hs b/services/galley/test/integration/API/Util.hs index bef749c0f54..2acefc220a1 100644 --- a/services/galley/test/integration/API/Util.hs +++ b/services/galley/test/integration/API/Util.hs @@ -2615,7 +2615,8 @@ mkProfile quid name = profileSupportedProtocols = defSupportedProtocols, profileType = UserTypeRegular, profileApp = Nothing, - profileSearchable = True + profileSearchable = True, + profileContactStatus = Nothing } -- mock federator From c6f015bdfb285c457b2cee6f84c56df70690a777 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Fri, 24 Jul 2026 21:59:54 +0200 Subject: [PATCH 031/113] WPB-27175: Add Galley meetings email config plumbing (#5346) --- .../wpb-27175-meetings-email.md | 11 +++ .../templates/galley/configmap.yaml | 28 ++++++- .../wire-server/templates/galley/secret.yaml | 4 + charts/wire-server/values.yaml | 22 ++++++ .../src/developer/reference/config-options.md | 33 ++++++++ hack/helm_vars/wire-server/values.yaml.gotmpl | 7 ++ .../src/Wire/EmailSending/Options.hs | 78 +++++++++++++++++++ .../src/Wire/Options/Galley.hs | 22 +++++- libs/wire-subsystems/wire-subsystems.cabal | 1 + services/brig/src/Brig/AWS.hs | 7 +- services/brig/src/Brig/App.hs | 11 +-- services/brig/src/Brig/Options.hs | 47 +---------- services/brig/src/Brig/Run.hs | 2 +- services/brig/test/integration/Run.hs | 8 +- services/galley/galley.integration.yaml | 8 ++ 15 files changed, 227 insertions(+), 62 deletions(-) create mode 100644 changelog.d/0-release-notes/wpb-27175-meetings-email.md create mode 100644 libs/wire-subsystems/src/Wire/EmailSending/Options.hs diff --git a/changelog.d/0-release-notes/wpb-27175-meetings-email.md b/changelog.d/0-release-notes/wpb-27175-meetings-email.md new file mode 100644 index 00000000000..f5a5526ff19 --- /dev/null +++ b/changelog.d/0-release-notes/wpb-27175-meetings-email.md @@ -0,0 +1,11 @@ +* Galley has a new optional `settings.meetings.email` configuration block + (WPB-27175) for sending meeting-invitation emails to invited external + addresses. It takes a required `from` sender, an optional `replyTo` address, + and a `transport` that selects AWS SES or SMTP (the same shape Brig uses). + When the block is unset, meeting invitation emails are disabled. For SMTP, + set `galley.secrets.smtpPassword` (mounted at + `/etc/wire/galley/secrets/smtp-password.txt`) and point + `settings.meetings.email.smtp.passwordFile` at that path; the Galley ConfigMap + injects it into `transport.smtpCredentials.smtpPassword`, the same pattern + Brig uses for `smtp.passwordFile`. This change adds the + configuration plumbing only; email sending itself lands in a follow-up. diff --git a/charts/wire-server/templates/galley/configmap.yaml b/charts/wire-server/templates/galley/configmap.yaml index b91f6f083c2..90ed86d571f 100644 --- a/charts/wire-server/templates/galley/configmap.yaml +++ b/charts/wire-server/templates/galley/configmap.yaml @@ -113,7 +113,33 @@ data: checkGroupInfo: {{ .settings.checkGroupInfo }} {{- end }} meetings: - {{- toYaml .settings.meetings | nindent 8 }} + {{- with .settings.meetings }} + {{- if .validityPeriod }} + validityPeriod: {{ .validityPeriod }} + {{- end }} + {{- with .email }} + email: + from: {{ required "Missing value: galley.config.settings.meetings.email.from" .from | quote }} + {{- if .replyTo }} + replyTo: {{ .replyTo | quote }} + {{- end }} + transport: + {{- if .useSES }} + sesQueue: {{ required "Missing value: galley.config.settings.meetings.email.aws.sesQueue" .aws.sesQueue }} + sesEndpoint: {{ .aws.sesEndpoint | quote }} + {{- else }} + smtpEndpoint: + host: {{ .smtp.host }} + port: {{ .smtp.port }} + smtpConnType: {{ .smtp.connType }} + {{- if .smtp.username }} + smtpCredentials: + smtpUsername: {{ .smtp.username }} + smtpPassword: {{ .smtp.passwordFile }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} featureFlags: sso: {{ .settings.featureFlags.sso }} legalhold: {{ .settings.featureFlags.legalhold }} diff --git a/charts/wire-server/templates/galley/secret.yaml b/charts/wire-server/templates/galley/secret.yaml index 8425394ddd4..beb586c8745 100644 --- a/charts/wire-server/templates/galley/secret.yaml +++ b/charts/wire-server/templates/galley/secret.yaml @@ -27,3 +27,7 @@ data: {{- if .Values.galley.secrets.pgPassword }} pgPassword: {{ .Values.galley.secrets.pgPassword | b64enc | quote }} {{- end }} + + {{- if .Values.galley.secrets.smtpPassword }} + smtp-password.txt: {{ .Values.galley.secrets.smtpPassword | b64enc | quote }} + {{- end }} diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml index 34cd2297051..af947278084 100644 --- a/charts/wire-server/values.yaml +++ b/charts/wire-server/values.yaml @@ -141,6 +141,24 @@ galley: meetings: validityPeriod: "48h" + # Optional. When set, meeting invitation emails are sent with this + # sender over the configured transport (SES xor SMTP). `useSES` selects + # the transport; `aws` is used when true, `smtp` when false (mirrors + # brig.config.useSES). The ConfigMap renders these into the nested + # `transport:` object that Galley parses. + # email: + # from: meetings@example.com # required when email present + # replyTo: noreply@example.com # optional + # useSES: true + # aws: # used when useSES: true + # sesQueue: wire-meetings-email-feedback + # sesEndpoint: https://email.us-east-1.amazonaws.com + # smtp: # used when useSES: false + # host: smtp.example.com + # port: 587 + # connType: tls + # username: meetings # optional; enables smtpCredentials + # passwordFile: /etc/wire/galley/secrets/smtp-password.txt # To disable proteus for new federated conversations: # federationProtocols: ["mls"] @@ -338,6 +356,10 @@ galley: annotations: {} automountServiceAccountToken: true + # Optional secret keys (see templates/galley/secret.yaml): + # smtpPassword: # mounted at /etc/wire/galley/secrets/smtp-password.txt; + # # consumed via settings.meetings.email.smtp.passwordFile + # # (mirrors brig's smtp.passwordFile) secrets: {} podSecurityContext: diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index 61a79ac6298..947c7619912 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -249,6 +249,39 @@ The lock status for individual teams can be changed via the internal API (`PUT / The feature status for individual teams can be changed via the public API (if the feature is unlocked). +### Meetings email sender and transport + +The optional `settings.meetings.email` block enables emailing meeting +invitations to external addresses. `from` is required; `replyTo` is optional. +`transport` is an AWS SES or SMTP value (the same shape Brig uses for its +email transport). When the block is absent, no meeting invitation emails are +sent. + +```yaml +# galley.yaml +settings: + meetings: + email: + from: meetings@example.com + replyTo: noreply@example.com + transport: # SES: + sesQueue: wire-meetings-email-feedback + sesEndpoint: https://email.us-east-1.amazonaws.com + # transport: # SMTP (xor SES): + # smtpEndpoint: { host: smtp.example.com, port: 587 } + # smtpConnType: tls + # smtpCredentials: + # smtpUsername: meetings + # smtpPassword: /etc/wire/galley/secrets/smtp-password.txt +``` + +For SMTP, the Helm value `galley.config.settings.meetings.email.smtp.passwordFile` +points at the path where the SMTP password is read, and +`galley.secrets.smtpPassword` holds the value (mounted at +`/etc/wire/galley/secrets/smtp-password.txt`). The Galley ConfigMap injects that +path into `transport.smtpCredentials.smtpPassword`, the same pattern Brig uses +for `smtp.passwordFile`. + ### Meetings Premium (deprecated) > **Deprecated (WPB-26771).** The `meetingsPremium` feature flag no longer diff --git a/hack/helm_vars/wire-server/values.yaml.gotmpl b/hack/helm_vars/wire-server/values.yaml.gotmpl index e7eac919d88..bd6cdadf2a8 100644 --- a/hack/helm_vars/wire-server/values.yaml.gotmpl +++ b/hack/helm_vars/wire-server/values.yaml.gotmpl @@ -317,6 +317,13 @@ galley: disabledAPIVersions: [] meetings: validityPeriod: "5s" + email: + from: meetings@example.com + replyTo: noreply@example.com + useSES: true + aws: + sesQueue: integration-galley-meetings-events + sesEndpoint: http://fake-aws-ses:4569 # These values are insecure, against anyone getting hold of the hash, # but its not a concern for the integration tests. diff --git a/libs/wire-subsystems/src/Wire/EmailSending/Options.hs b/libs/wire-subsystems/src/Wire/EmailSending/Options.hs new file mode 100644 index 00000000000..8fe5f1f00b9 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/EmailSending/Options.hs @@ -0,0 +1,78 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Shared email-transport option types used by both Brig and Galley. Moved +-- here from "Brig.Options" so both services parse the same SES\/SMTP shape. +module Wire.EmailSending.Options + ( EmailAWSOpts (..), + EmailSMTPCredentials (..), + EmailSMTPOpts (..), + EmailOpts (..), + ) +where + +import Data.Aeson (FromJSON, parseJSON) +import Imports +import Util.Options (AWSEndpoint, Endpoint, FilePathSecrets) +import Wire.EmailSending.SMTP (SMTPConnType (..)) + +data EmailAWSOpts = EmailAWSOpts + { -- | Event feedback queue for SES + -- (e.g. for email bounces and complaints) + sesQueue :: !Text, + -- | AWS SES endpoint + sesEndpoint :: !AWSEndpoint + } + deriving (Show, Generic) + +instance FromJSON EmailAWSOpts + +data EmailSMTPCredentials = EmailSMTPCredentials + { -- | Username to authenticate + -- against the SMTP server + smtpUsername :: !Text, + -- | File containing password to + -- authenticate against the SMTP server + smtpPassword :: !FilePathSecrets + } + deriving (Show, Generic) + +instance FromJSON EmailSMTPCredentials + +data EmailSMTPOpts = EmailSMTPOpts + { -- | Hostname of the SMTP server to connect to + smtpEndpoint :: !Endpoint, + smtpCredentials :: !(Maybe EmailSMTPCredentials), + -- | Which type of connection to use + -- against the SMTP server {tls,ssl,plain} + smtpConnType :: !SMTPConnType + } + deriving (Show, Generic) + +instance FromJSON EmailSMTPOpts + +data EmailOpts + = EmailAWS EmailAWSOpts + | EmailSMTP EmailSMTPOpts + deriving (Show, Generic) + +instance FromJSON EmailOpts where + parseJSON o = + EmailAWS + <$> parseJSON o + <|> EmailSMTP + <$> parseJSON o diff --git a/libs/wire-subsystems/src/Wire/Options/Galley.hs b/libs/wire-subsystems/src/Wire/Options/Galley.hs index f9040bdde90..377fa557f51 100644 --- a/libs/wire-subsystems/src/Wire/Options/Galley.hs +++ b/libs/wire-subsystems/src/Wire/Options/Galley.hs @@ -61,6 +61,8 @@ module Wire.Options.Galley checkGroupInfo, meetings, validityPeriod, + email, + MeetingsEmailConfig (..), postgresMigration, PostgresMigrationOpts (..), StorageLocation (..), @@ -70,7 +72,7 @@ module Wire.Options.Galley ) where -import Control.Lens hiding (Level, (.=)) +import Control.Lens hiding (Level, from, (.=)) import Data.Aeson (FromJSON (..)) import Data.Aeson.TH (deriveFromJSON) import Data.Domain (Domain) @@ -87,6 +89,8 @@ import Wire.API.Conversation.Protocol import Wire.API.Routes.Version import Wire.API.Team.FeatureFlags import Wire.API.Team.Member +import Wire.API.User.Identity (EmailAddress) +import Wire.EmailSending.Options (EmailOpts) import Wire.Options.Keys (MLSPrivateKeyPaths) import Wire.PostgresMigrationOpts import Wire.RateLimit.Interpreter (RateLimitConfig) @@ -176,10 +180,24 @@ data Settings = Settings data MeetingsConfig = MeetingsConfig { -- | Validity period of a meeting. After this time, the meeting is considered expired. - _validityPeriod :: !(Maybe Duration) + _validityPeriod :: !(Maybe Duration), + -- | Email sending configuration for meeting invitations. When unset, no + -- meeting invitation emails are sent. + _email :: !(Maybe MeetingsEmailConfig) } deriving (Show, Generic) +data MeetingsEmailConfig = MeetingsEmailConfig + { -- | 'From' sender for meeting emails. Required when the block is present. + _from :: !EmailAddress, + -- | Optional 'Reply-To' address. + _replyTo :: !(Maybe EmailAddress), + -- | Outbound transport: AWS SES or SMTP ('Wire.EmailSending.Options.EmailOpts'). + _transport :: !EmailOpts + } + deriving (Show, Generic) + +deriveFromJSON toOptionFieldName ''MeetingsEmailConfig deriveFromJSON toOptionFieldName ''MeetingsConfig deriveFromJSON toOptionFieldName ''Settings diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 531d31839b7..49e7c16e1f7 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -319,6 +319,7 @@ library Wire.DomainVerificationChallengeStore.DualWrite Wire.DomainVerificationChallengeStore.Postgres Wire.EmailSending + Wire.EmailSending.Options Wire.EmailSending.SES Wire.EmailSending.SMTP Wire.EmailSubsystem diff --git a/services/brig/src/Brig/AWS.hs b/services/brig/src/Brig/AWS.hs index 5bcbf90ecd5..f75e983bbc2 100644 --- a/services/brig/src/Brig/AWS.hs +++ b/services/brig/src/Brig/AWS.hs @@ -69,6 +69,7 @@ import UnliftIO.Async import UnliftIO.Exception import Util.Options import Wire.AWS (canRetry, sendCatch) +import Wire.EmailSending.Options qualified as EmailOpt data Env = Env { _logger :: !Logger, @@ -99,11 +100,11 @@ newtype Amazon a = Amazon instance MonadLogger Amazon where log l m = view logger >>= \g -> Logger.log g l m -mkEnv :: Logger -> Opt.AWSOpts -> Maybe Opt.EmailAWSOpts -> Manager -> IO Env +mkEnv :: Logger -> Opt.AWSOpts -> Maybe EmailOpt.EmailAWSOpts -> Manager -> IO Env mkEnv lgr opts emailOpts mgr = do let g = Logger.clone (Just "aws.brig") lgr let pk = Opt.prekeyTable opts - let sesEndpoint = mkEndpoint SES.defaultService . Opt.sesEndpoint <$> emailOpts + let sesEndpoint = mkEndpoint SES.defaultService . EmailOpt.sesEndpoint <$> emailOpts let dynamoEndpoint = mkEndpoint DDB.defaultService <$> Opt.dynamoDBEndpoint opts e <- mkAwsEnv @@ -111,7 +112,7 @@ mkEnv lgr opts emailOpts mgr = do sesEndpoint dynamoEndpoint (mkEndpoint SQS.defaultService (Opt.sqsEndpoint opts)) - sq <- maybe (pure Nothing) (fmap Just . getQueueUrl e . Opt.sesQueue) emailOpts + sq <- maybe (pure Nothing) (fmap Just . getQueueUrl e . EmailOpt.sesQueue) emailOpts jq <- maybe (pure Nothing) (fmap Just . getQueueUrl e) (Opt.userJournalQueue opts) pure (Env g sq jq pk e) where diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index dd1192ccf3f..315b073524f 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -164,6 +164,7 @@ import Wire.API.Routes.Version import Wire.API.User.Identity import Wire.AuthenticationSubsystem.Config (ZAuthEnv) import Wire.AuthenticationSubsystem.Config qualified as AuthenticationSubsystem +import Wire.EmailSending.Options qualified as EmailOpt import Wire.EmailSending.SMTP qualified as SMTP import Wire.EmailSubsystem.Template (Localised, TemplateBranding, forLocale) import Wire.EmailSubsystem.Templates.User @@ -321,15 +322,15 @@ newEnv opts = do postgresMigration = opts.postgresMigration } where - emailConn _ (Opt.EmailAWS aws) = pure (Just aws, Nothing) - emailConn lgr (Opt.EmailSMTP s) = do + emailConn _ (EmailOpt.EmailAWS aws) = pure (Just aws, Nothing) + emailConn lgr (EmailOpt.EmailSMTP s) = do let h = s.smtpEndpoint.host p = Just . fromInteger . toInteger $ s.smtpEndpoint.port - smtpCredentials <- case Opt.smtpCredentials s of - Just (Opt.EmailSMTPCredentials u p') -> do + smtpCredentials <- case EmailOpt.smtpCredentials s of + Just (EmailOpt.EmailSMTPCredentials u p') -> do Just . (SMTP.Username u,) . SMTP.Password <$> initCredentials p' _ -> pure Nothing - smtp <- SMTP.initSMTP lgr h p smtpCredentials (Opt.smtpConnType s) + smtp <- SMTP.initSMTP lgr h p smtpCredentials (EmailOpt.smtpConnType s) pure (Nothing, Just smtp) mkEndpoint service = RPC.host (encodeUtf8 service.host) . RPC.port service.port $ RPC.empty diff --git a/services/brig/src/Brig/Options.hs b/services/brig/src/Brig/Options.hs index da75b90138a..df05f68fd7d 100644 --- a/services/brig/src/Brig/Options.hs +++ b/services/brig/src/Brig/Options.hs @@ -57,7 +57,7 @@ import Wire.API.Team.Feature import Wire.API.User import Wire.AuthenticationSubsystem.Config (ZAuthSettings) import Wire.AuthenticationSubsystem.Cookie.Limit -import Wire.EmailSending.SMTP (SMTPConnType (..)) +import Wire.EmailSending.Options (EmailOpts) import Wire.EmailSubsystem.Template (TeamOpts) import Wire.PostgresMigrationOpts import Wire.RateLimit.Interpreter @@ -109,41 +109,6 @@ data AWSOpts = AWSOpts instance FromJSON AWSOpts -data EmailAWSOpts = EmailAWSOpts - { -- | Event feedback queue for SES - -- (e.g. for email bounces and complaints) - sesQueue :: !Text, - -- | AWS SES endpoint - sesEndpoint :: !AWSEndpoint - } - deriving (Show, Generic) - -instance FromJSON EmailAWSOpts - -data EmailSMTPCredentials = EmailSMTPCredentials - { -- | Username to authenticate - -- against the SMTP server - smtpUsername :: !Text, - -- | File containing password to - -- authenticate against the SMTP server - smtpPassword :: !FilePathSecrets - } - deriving (Show, Generic) - -instance FromJSON EmailSMTPCredentials - -data EmailSMTPOpts = EmailSMTPOpts - { -- | Hostname of the SMTP server to connect to - smtpEndpoint :: !Endpoint, - smtpCredentials :: !(Maybe EmailSMTPCredentials), - -- | Which type of connection to use - -- against the SMTP server {tls,ssl,plain} - smtpConnType :: !SMTPConnType - } - deriving (Show, Generic) - -instance FromJSON EmailSMTPOpts - data StompOpts = StompOpts { host :: !Text, port :: !Int, @@ -224,16 +189,6 @@ data ProviderOpts = ProviderOpts instance FromJSON ProviderOpts -data EmailOpts - = EmailAWS EmailAWSOpts - | EmailSMTP EmailSMTPOpts - deriving (Show, Generic) - -instance FromJSON EmailOpts where - parseJSON o = - EmailAWS <$> parseJSON o - <|> EmailSMTP <$> parseJSON o - data EmailSMSOpts = EmailSMSOpts { email :: !EmailOpts, general :: !EmailSMSGeneralOpts, diff --git a/services/brig/src/Brig/Run.hs b/services/brig/src/Brig/Run.hs index 6ef4967bcde..d6ce0211772 100644 --- a/services/brig/src/Brig/Run.hs +++ b/services/brig/src/Brig/Run.hs @@ -31,7 +31,7 @@ import Brig.CanonicalInterpreter import Brig.Effects.UserPendingActivationStore (UserPendingActivation (UserPendingActivation), UserPendingActivationStore) import Brig.Effects.UserPendingActivationStore qualified as UsersPendingActivationStore import Brig.InternalEvent.Process qualified as Internal -import Brig.Options hiding (internalEvents, sesQueue) +import Brig.Options hiding (internalEvents) import Brig.Queue qualified as Queue import Brig.Version import Control.Concurrent.Async qualified as Async diff --git a/services/brig/test/integration/Run.hs b/services/brig/test/integration/Run.hs index 2fd4e49c3a8..e41bab0ff0f 100644 --- a/services/brig/test/integration/Run.hs +++ b/services/brig/test/integration/Run.hs @@ -64,6 +64,7 @@ import Util.Test.SQS qualified as SQS import Web.HttpApiData import Wire.API.Federation.API import Wire.API.Routes.Version +import Wire.EmailSending.Options qualified as EmailOpt data BackendConf = BackendConf { remoteBrig :: Endpoint, @@ -187,11 +188,10 @@ runTests iConf brigOpts otherArgs = do _ -> s latestVersion :: Version latestVersion = maxBound - - parseEmailAWSOpts :: IO (Maybe Opts.EmailAWSOpts) + parseEmailAWSOpts :: IO (Maybe EmailOpt.EmailAWSOpts) parseEmailAWSOpts = case Opts.email . Opts.emailSMS $ brigOpts of - (Opts.EmailAWS aws) -> pure (Just aws) - (Opts.EmailSMTP _) -> pure Nothing + (EmailOpt.EmailAWS aws) -> pure (Just aws) + (EmailOpt.EmailSMTP _) -> pure Nothing main :: IO () main = withOpenSSL $ do diff --git a/services/galley/galley.integration.yaml b/services/galley/galley.integration.yaml index 55ef99cb81c..47980040c1e 100644 --- a/services/galley/galley.integration.yaml +++ b/services/galley/galley.integration.yaml @@ -93,6 +93,14 @@ settings: meetings: validityPeriod: "5s" + email: + from: meetings@integration.example.com + replyTo: reply@integration.example.com + transport: + smtpEndpoint: + host: localhost + port: 2525 + smtpConnType: plain # We explicitly do not disable any API version. Please make sure the configuration value is the same in all these configs: # brig, cannon, cargohold, galley, gundeck, proxy, spar. From 1837cf8cfabb7c6b6619cf82c3cd367ae136c2a9 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Sat, 25 Jul 2026 10:05:16 +0200 Subject: [PATCH 032/113] WPB-27373: drop trial from Meeting at API version V17 (#5363) --- .../wpb-27373-meeting-drop-trial | 1 + integration/test/Test/Meetings.hs | 33 --------- libs/wire-api/src/Wire/API/Meeting.hs | 64 ++++++++++++++-- .../Wire/API/Routes/Public/Galley/Meetings.hs | 74 +++++++++++++++++-- .../golden/Test/Wire/API/Golden/Manual.hs | 11 +++ .../Test/Wire/API/Golden/Manual/Meeting.hs | 58 +++++++++++++++ .../golden/testObject_Meeting_manual_1.json | 22 ++++++ .../golden/testObject_Meeting_manual_2.json | 24 ++++++ .../testObject_Meeting_v15_manual_1.json | 23 ++++++ .../testObject_Meeting_v15_manual_2.json | 25 +++++++ .../unit/Test/Wire/API/Roundtrip/Aeson.hs | 34 ++++++++- libs/wire-api/wire-api.cabal | 1 + .../src/Wire/MeetingsSubsystem/Interpreter.hs | 1 - .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 45 ----------- .../galley/src/Galley/API/Public/Meetings.hs | 6 +- 15 files changed, 328 insertions(+), 94 deletions(-) create mode 100644 changelog.d/1-api-changes/wpb-27373-meeting-drop-trial create mode 100644 libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/Meeting.hs create mode 100644 libs/wire-api/test/golden/testObject_Meeting_manual_1.json create mode 100644 libs/wire-api/test/golden/testObject_Meeting_manual_2.json create mode 100644 libs/wire-api/test/golden/testObject_Meeting_v15_manual_1.json create mode 100644 libs/wire-api/test/golden/testObject_Meeting_v15_manual_2.json diff --git a/changelog.d/1-api-changes/wpb-27373-meeting-drop-trial b/changelog.d/1-api-changes/wpb-27373-meeting-drop-trial new file mode 100644 index 00000000000..7b7300f58a6 --- /dev/null +++ b/changelog.d/1-api-changes/wpb-27373-meeting-drop-trial @@ -0,0 +1 @@ +The meetings endpoints (POST /meetings, PUT /meetings/{domain}/{id}, GET /meetings/{domain}/{id}, GET /meetings/list) drop the deprecated `trial` field from the `Meeting` response starting at API version V17. On V15–V16 the field is still present but always returns `false` (team meetings are never trial; see WPB-26771). The underlying storage is unchanged. diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs index c037f062de3..96e0d243353 100644 --- a/integration/test/Test/Meetings.hs +++ b/integration/test/Test/Meetings.hs @@ -127,39 +127,6 @@ testMeetingGetNotFound = do getMeeting owner "example.com" fakeMeetingId >>= assertLabel 404 "meeting-not-found" --- Test that personal (non-team) users create trial meetings -testMeetingCreatePersonalUserTrial :: (HasCallStack) => App () -testMeetingCreatePersonalUserTrial = do - personalUser <- randomUser OwnDomain def - now <- liftIO getCurrentTime - let startTime = addUTCTime 3600 now - endTime = addUTCTime 7200 now - newMeeting = defaultMeetingJson "Personal Meeting" startTime endTime [] - - r <- postMeetings personalUser newMeeting - assertSuccess r - - meeting <- getJSON 201 r - meeting %. "trial" `shouldMatch` True - --- | Test that team members create non-trial meetings. The deprecated --- `meetingsPremium` flag no longer affects this; team meetings are always --- non-trial (see WPB-26771). -testMeetingCreateTeamNonTrial :: (HasCallStack) => App () -testMeetingCreateTeamNonTrial = do - (owner, _tid, _members) <- createTeam OwnDomain 1 - - now <- liftIO getCurrentTime - let startTime = addUTCTime 3600 now - endTime = addUTCTime 7200 now - newMeeting = defaultMeetingJson "Team Meeting" startTime endTime [] - - r <- postMeetings owner newMeeting - assertSuccess r - - meeting <- getJSON 201 r - meeting %. "trial" `shouldMatch` False - -- Test that disabled MeetingsConfig feature blocks creation testMeetingsConfigDisabledBlocksCreate :: (HasCallStack) => App () testMeetingsConfigDisabledBlocksCreate = do diff --git a/libs/wire-api/src/Wire/API/Meeting.hs b/libs/wire-api/src/Wire/API/Meeting.hs index 2a2a4cff8d0..a894c2cfa27 100644 --- a/libs/wire-api/src/Wire/API/Meeting.hs +++ b/libs/wire-api/src/Wire/API/Meeting.hs @@ -18,6 +18,7 @@ module Wire.API.Meeting where import Control.Lens ((?~)) +import Data.Aeson (toJSON) import Data.Id (ConvId, MeetingId, UserId) import Data.Int qualified as DI import Data.Json.Util (utcTimeSchema) @@ -30,6 +31,8 @@ import Deriving.Aeson import Imports import Wire.API.Conversation (Conversation, GroupConvType) import Wire.API.PostgresMarshall (PostgresMarshall (..), PostgresUnmarshall (..)) +import Wire.API.Routes.Version +import Wire.API.Routes.Versioned (Versioned (..)) import Wire.API.User.Identity (EmailAddress) import Wire.Arbitrary (Arbitrary, GenericUniform (..)) @@ -43,7 +46,6 @@ data Meeting = Meeting recurrence :: Maybe Recurrence, conversationId :: Qualified ConvId, invitedEmails :: [EmailAddress], - trial :: Bool, createdAt :: UTCTime, updatedAt :: UTCTime } @@ -62,13 +64,39 @@ meetingObject = <*> (.recurrence) .= maybe_ (optField "recurrence" schema) <*> (.conversationId) .= field "qualified_conversation" schema <*> (.invitedEmails) .= field "invited_emails" (array schema) - <*> (.trial) .= field "trial" schema <*> (.createdAt) .= field "created_at" utcTimeSchema <*> (.updatedAt) .= field "updated_at" utcTimeSchema +-- | 'meetingObject' for a given API version. Legacy versions (< V17) additionally +-- render the deprecated @trial@ field (always 'False'); V17 and later omit it. +meetingObjectVersioned :: Maybe Version -> ObjectSchema SwaggerDoc Meeting +meetingObjectVersioned v + | maybe False (< V17) v = + meetingObject + <* ( const () + .= fieldWithDocModifier + "trial" + (description ?~ "Deprecated. Always false; team meetings are never trial.") + (c (False :: Bool)) + ) + | otherwise = meetingObject + where + -- Constant schema that always encodes @val@ and decodes to @()@, cf. the + -- @managed@ field of 'Wire.API.Conversation.ConvTeamInfo'. + c :: (ToJSON a) => a -> ValueSchema SwaggerDoc () + c val = mkSchema mempty (const (pure ())) (const (pure (toJSON val))) + +-- | Swagger-named ('ValueSchema') form of 'meetingObjectVersioned', used by the +-- plain 'ToSchema' instance and the versioned 'Versioned' instances. +meetingSchema :: Maybe Version -> ValueSchema NamedSwaggerDoc Meeting +meetingSchema v = + versionedObjectWithDocModifier v (description ?~ "A scheduled meeting") (meetingObjectVersioned v) + instance ToSchema Meeting where - schema = - objectWithDocModifier (description ?~ "A scheduled meeting") meetingObject + schema = meetingSchema Nothing + +instance ToSchema (Versioned 'V15 Meeting) where + schema = Versioned <$> unVersioned .= meetingSchema (Just V15) -- | A 'Meeting' extended with the full 'Conversation' associated with it, as -- returned when creating or updating a meeting. The underlying 'Meeting' is @@ -83,12 +111,32 @@ data MeetingWithConversation = MeetingWithConversation deriving (ToJSON, FromJSON, S.ToSchema) via (Schema MeetingWithConversation) deriving (Arbitrary) via (GenericUniform MeetingWithConversation) +meetingWithConversationObject :: Maybe Version -> ObjectSchema SwaggerDoc MeetingWithConversation +meetingWithConversationObject v = + MeetingWithConversation + <$> (.meeting) .= meetingObjectVersioned v + <*> (.conversation) .= field "conversation" schema + +meetingWithConversationSchema :: Maybe Version -> ValueSchema NamedSwaggerDoc MeetingWithConversation +meetingWithConversationSchema v = + versionedObjectWithDocModifier + v + (description ?~ "A scheduled meeting with its associated conversation") + (meetingWithConversationObject v) + instance ToSchema MeetingWithConversation where + schema = meetingWithConversationSchema Nothing + +instance ToSchema (Versioned 'V15 MeetingWithConversation) where + schema = Versioned <$> unVersioned .= meetingWithConversationSchema (Just V15) + +-- | Legacy 'Meeting' list (V16) still renders the deprecated @trial@ field +-- (always 'False') for backwards compatibility. +instance {-# OVERLAPPING #-} ToSchema (Versioned 'V16 [Meeting]) where schema = - objectWithDocModifier (description ?~ "A scheduled meeting with its associated conversation") $ - MeetingWithConversation - <$> (.meeting) .= meetingObject - <*> (.conversation) .= field "conversation" schema + Versioned + <$> unVersioned + .= named "MeetingListV16" (array (meetingSchema (Just V16))) -- | Request to create a new meeting data NewMeeting = NewMeeting diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs index 2349a5452d7..a58380eefba 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs @@ -27,12 +27,14 @@ import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named import Wire.API.Routes.Public import Wire.API.Routes.Version +import Wire.API.Routes.Versioned type MeetingsAPI = Named - "create-meeting" + "create-meeting@v15" ( Summary "Create a new meeting" :> From 'V15 + :> Until 'V17 :> ZLocalUser :> "meetings" :> ReqBody '[JSON] NewMeeting @@ -41,13 +43,47 @@ type MeetingsAPI = :> MultiVerb 'POST '[JSON] - '[Respond 201 "Meeting created" MeetingWithConversation] + '[VersionedRespond 'V15 201 "Meeting created" MeetingWithConversation] MeetingWithConversation ) :<|> Named - "update-meeting" + "create-meeting" + ( Summary "Create a new meeting" + :> From 'V17 + :> ZLocalUser + :> "meetings" + :> ReqBody '[JSON] NewMeeting + :> CanThrow 'InvalidOperation + :> CanThrow UnreachableBackends + :> MultiVerb + 'POST + '[JSON] + '[Respond 201 "Meeting created" MeetingWithConversation] + MeetingWithConversation + ) + :<|> Named + "update-meeting@v15" ( Summary "Update an existing meeting" :> From 'V15 + :> Until 'V17 + :> ZLocalUser + :> "meetings" + :> Capture "domain" Domain + :> Capture "id" MeetingId + :> CanThrow 'MeetingNotFound + :> CanThrow 'AccessDenied + :> CanThrow 'InvalidOperation + :> ReqBody '[JSON] UpdateMeeting + :> MultiVerb + 'PUT + '[JSON] + '[VersionedRespond 'V15 200 "Meeting updated" MeetingWithConversation] + MeetingWithConversation + ) + :<|> Named + "update-meeting" + ( Summary "Update an existing meeting" + :> From 'V17 :> ZLocalUser :> "meetings" :> Capture "domain" Domain @@ -80,9 +116,24 @@ type MeetingsAPI = () ) :<|> Named - "get-meeting" + "get-meeting@v15" ( Summary "Get a single meeting by ID" :> From 'V15 + :> Until 'V17 + :> ZLocalUser + :> "meetings" + :> Capture "domain" Domain + :> Capture "id" MeetingId + :> CanThrow 'MeetingNotFound + :> MultiVerb1 + 'GET + '[JSON] + (VersionedRespond 'V15 200 "A single meeting by ID" Meeting) + ) + :<|> Named + "get-meeting" + ( Summary "Get a single meeting by ID" + :> From 'V17 :> ZLocalUser :> "meetings" :> Capture "domain" Domain @@ -91,9 +142,22 @@ type MeetingsAPI = :> Get '[JSON] Meeting ) :<|> Named - "list-meetings" + "list-meetings@v16" ( Summary "List all meetings for the authenticated user" :> From 'V16 + :> Until 'V17 + :> ZLocalUser + :> "meetings" + :> "list" + :> MultiVerb1 + 'GET + '[JSON] + (VersionedRespond 'V16 200 "List of meetings for the authenticated user" [Meeting]) + ) + :<|> Named + "list-meetings" + ( Summary "List all meetings for the authenticated user" + :> From 'V17 :> ZLocalUser :> "meetings" :> "list" diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs index fe5e8627f57..89eefb6e241 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs @@ -50,6 +50,7 @@ import Test.Wire.API.Golden.Manual.ListUsersById import Test.Wire.API.Golden.Manual.LoginId_user import Test.Wire.API.Golden.Manual.Login_user import Test.Wire.API.Golden.Manual.MLSKeys +import Test.Wire.API.Golden.Manual.Meeting import Test.Wire.API.Golden.Manual.MeetingEvent import Test.Wire.API.Golden.Manual.Pagination import Test.Wire.API.Golden.Manual.Presence @@ -168,6 +169,16 @@ tests = (testObject_Event_meeting_update_manual_1, "testObject_Event_meeting_update_manual_1.json"), (testObject_Event_meeting_delete_manual_1, "testObject_Event_meeting_delete_manual_1.json") ], + testGroup "Meeting V15" $ + testObjects + [ (Versioned @'V15 testObject_Meeting_manual_1, "testObject_Meeting_v15_manual_1.json"), + (Versioned @'V15 testObject_Meeting_manual_2, "testObject_Meeting_v15_manual_2.json") + ], + testGroup "Meeting" $ + testObjects + [ (testObject_Meeting_manual_1, "testObject_Meeting_manual_1.json"), + (testObject_Meeting_manual_2, "testObject_Meeting_manual_2.json") + ], testGroup "GetPaginatedConversationIds" $ testObjects [ (testObject_GetPaginatedConversationIds_1, "testObject_GetPaginatedConversationIds_1.json"), diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/Meeting.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/Meeting.hs new file mode 100644 index 00000000000..1b08a627668 --- /dev/null +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/Meeting.hs @@ -0,0 +1,58 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Wire.API.Golden.Manual.Meeting where + +import Data.Domain (Domain (..)) +import Data.Id +import Data.Qualified (Qualified (..)) +import Data.Range (unsafeRange) +import Data.Time +import Data.UUID qualified as UUID +import Imports +import Wire.API.Meeting +import Wire.API.User (unsafeEmailAddress) + +testObject_Meeting_manual_1 :: Meeting +testObject_Meeting_manual_1 = + Meeting + { id = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001")), qDomain = Domain {_domainText = "example.com"}}, + title = unsafeRange "Weekly Sync", + creator = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000002-0000-0000-0000-000000000002")), qDomain = Domain {_domainText = "example.com"}}, + startTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + endTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 3600}, + recurrence = Nothing, + conversationId = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000003-0000-0000-0000-000000000003")), qDomain = Domain {_domainText = "example.com"}}, + invitedEmails = [unsafeEmailAddress "someone" "example.com"], + createdAt = UTCTime {utctDay = ModifiedJulianDay 58118, utctDayTime = 0}, + updatedAt = UTCTime {utctDay = ModifiedJulianDay 58118, utctDayTime = 0} + } + +testObject_Meeting_manual_2 :: Meeting +testObject_Meeting_manual_2 = + Meeting + { id = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000004-0000-0000-0000-000000000004")), qDomain = Domain {_domainText = "example.com"}}, + title = unsafeRange "Sprint Planning", + creator = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000005-0000-0000-0000-000000000005")), qDomain = Domain {_domainText = "example.com"}}, + startTime = UTCTime {utctDay = ModifiedJulianDay 58120, utctDayTime = 0}, + endTime = UTCTime {utctDay = ModifiedJulianDay 58120, utctDayTime = 5400}, + recurrence = Just (Recurrence {freq = Weekly, interval = 1, until = Nothing}), + conversationId = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000006-0000-0000-0000-000000000006")), qDomain = Domain {_domainText = "example.com"}}, + invitedEmails = [], + createdAt = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + updatedAt = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0} + } diff --git a/libs/wire-api/test/golden/testObject_Meeting_manual_1.json b/libs/wire-api/test/golden/testObject_Meeting_manual_1.json new file mode 100644 index 00000000000..454e0339d71 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Meeting_manual_1.json @@ -0,0 +1,22 @@ +{ + "created_at": "2017-12-31T00:00:00Z", + "end_time": "2018-01-01T01:00:00Z", + "invited_emails": [ + "someone@example.com" + ], + "qualified_conversation": { + "domain": "example.com", + "id": "00000003-0000-0000-0000-000000000003" + }, + "qualified_creator": { + "domain": "example.com", + "id": "00000002-0000-0000-0000-000000000002" + }, + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "start_time": "2018-01-01T00:00:00Z", + "title": "Weekly Sync", + "updated_at": "2017-12-31T00:00:00Z" +} diff --git a/libs/wire-api/test/golden/testObject_Meeting_manual_2.json b/libs/wire-api/test/golden/testObject_Meeting_manual_2.json new file mode 100644 index 00000000000..916c1fdfbc0 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Meeting_manual_2.json @@ -0,0 +1,24 @@ +{ + "created_at": "2018-01-01T00:00:00Z", + "end_time": "2018-01-02T01:30:00Z", + "invited_emails": [], + "qualified_conversation": { + "domain": "example.com", + "id": "00000006-0000-0000-0000-000000000006" + }, + "qualified_creator": { + "domain": "example.com", + "id": "00000005-0000-0000-0000-000000000005" + }, + "qualified_id": { + "domain": "example.com", + "id": "00000004-0000-0000-0000-000000000004" + }, + "recurrence": { + "frequency": "weekly", + "interval": 1 + }, + "start_time": "2018-01-02T00:00:00Z", + "title": "Sprint Planning", + "updated_at": "2018-01-01T00:00:00Z" +} diff --git a/libs/wire-api/test/golden/testObject_Meeting_v15_manual_1.json b/libs/wire-api/test/golden/testObject_Meeting_v15_manual_1.json new file mode 100644 index 00000000000..3f41ec482af --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Meeting_v15_manual_1.json @@ -0,0 +1,23 @@ +{ + "created_at": "2017-12-31T00:00:00Z", + "end_time": "2018-01-01T01:00:00Z", + "invited_emails": [ + "someone@example.com" + ], + "qualified_conversation": { + "domain": "example.com", + "id": "00000003-0000-0000-0000-000000000003" + }, + "qualified_creator": { + "domain": "example.com", + "id": "00000002-0000-0000-0000-000000000002" + }, + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "start_time": "2018-01-01T00:00:00Z", + "title": "Weekly Sync", + "trial": false, + "updated_at": "2017-12-31T00:00:00Z" +} diff --git a/libs/wire-api/test/golden/testObject_Meeting_v15_manual_2.json b/libs/wire-api/test/golden/testObject_Meeting_v15_manual_2.json new file mode 100644 index 00000000000..caf3d890480 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Meeting_v15_manual_2.json @@ -0,0 +1,25 @@ +{ + "created_at": "2018-01-01T00:00:00Z", + "end_time": "2018-01-02T01:30:00Z", + "invited_emails": [], + "qualified_conversation": { + "domain": "example.com", + "id": "00000006-0000-0000-0000-000000000006" + }, + "qualified_creator": { + "domain": "example.com", + "id": "00000005-0000-0000-0000-000000000005" + }, + "qualified_id": { + "domain": "example.com", + "id": "00000004-0000-0000-0000-000000000004" + }, + "recurrence": { + "frequency": "weekly", + "interval": 1 + }, + "start_time": "2018-01-02T00:00:00Z", + "title": "Sprint Planning", + "trial": false, + "updated_at": "2018-01-01T00:00:00Z" +} diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs index f79bd4c229b..c1b8f909df7 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs @@ -17,7 +17,9 @@ module Test.Wire.API.Roundtrip.Aeson (tests) where -import Data.Aeson (FromJSON, Result (..), ToJSON, fromJSON, parseJSON, toJSON) +import Data.Aeson (FromJSON, Result (..), ToJSON, Value (..), fromJSON, parseJSON, toJSON) +import Data.Aeson.Key qualified as Key +import Data.Aeson.KeyMap qualified as KeyMap import Data.Aeson.Types (parseEither) import Data.Default (def) import Data.Id (ConvId) @@ -46,6 +48,7 @@ import Wire.API.Event.WebSocketProtocol qualified as EventWebSocketProtocol import Wire.API.FederationStatus qualified as FederationStatus import Wire.API.Jobs qualified as Jobs import Wire.API.Locale qualified as Locale +import Wire.API.Meeting qualified as Meeting import Wire.API.Message qualified as Message import Wire.API.OAuth qualified as OAuth import Wire.API.Properties qualified as Properties @@ -58,7 +61,9 @@ import Wire.API.Push.Token qualified as Push.Token import Wire.API.Routes.FederationDomainConfig qualified as FederationDomainConfig import Wire.API.Routes.Internal.Brig.EJPD qualified as EJPD import Wire.API.Routes.Internal.Galley.TeamsIntra qualified as TeamsIntra +import Wire.API.Routes.Version (Version (V15, V16)) import Wire.API.Routes.Version qualified as Routes.Version +import Wire.API.Routes.Versioned (Versioned (..)) import Wire.API.SystemSettings qualified as SystemSettings import Wire.API.Team qualified as Team import Wire.API.Team.Conversation qualified as Team.Conversation @@ -392,6 +397,10 @@ tests = testRoundTrip @Team.TeamSize, testRoundTrip @Team.LegalHold.Internal.LegalHoldService, testRoundTrip @Team.LegalHold.Internal.LegalHoldClientRequest, + meetingTrialVersioningTests, + testRoundTripWithSwagger @Meeting.Meeting, + testRoundTripWithSwagger @(Versioned 'V15 Meeting.Meeting), + testRoundTripWithSwagger @(Versioned 'V16 [Meeting.Meeting]), testFeatureFlagsCanonicalJsonRoundtrip ] @@ -440,3 +449,26 @@ testRoundTripWithSwagger = testProperty msg (trip .&&. scm) validatePrettyToJSON v ) $ isNothing (validatePrettyToJSON v) + +-- | Defends the API versioning contract introduced when dropping the deprecated +-- @trial@ field: legacy versions (< V17) still render @trial@ as @false@, while +-- the current version (V17) omits it entirely. +meetingTrialVersioningTests :: T.TestTree +meetingTrialVersioningTests = + T.testGroup + "Meeting trial field versioning" + [ testProperty "legacy (V15) response renders trial=false" $ + \(m :: Meeting.Meeting) -> + trialField (toJSON (Versioned @'V15 m)) === Just False, + testProperty "current (V17) response omits trial" $ + \(m :: Meeting.Meeting) -> + trialField (toJSON m) === Nothing + ] + +-- | Extract the @trial@ boolean from a 'Meeting' JSON object, if present. +trialField :: Value -> Maybe Bool +trialField = \case + Object o -> case KeyMap.lookup (Key.fromString "trial") o of + Just (Bool b) -> Just b + _ -> Nothing + _ -> Nothing diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index 6cdabd410b0..d7941674ad1 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -646,6 +646,7 @@ test-suite wire-api-golden-tests Test.Wire.API.Golden.Manual.ListUsersById Test.Wire.API.Golden.Manual.Login_user Test.Wire.API.Golden.Manual.LoginId_user + Test.Wire.API.Golden.Manual.Meeting Test.Wire.API.Golden.Manual.MeetingEvent Test.Wire.API.Golden.Manual.MLSKeys Test.Wire.API.Golden.Manual.Pagination diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs index abc9d884300..cee81b3b1ad 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs @@ -397,7 +397,6 @@ storedMeetingToMeeting domain sm = API.recurrence = sm.recurrence, API.conversationId = Qualified sm.conversationId domain, API.invitedEmails = sm.invitedEmails, - API.trial = sm.trial, API.createdAt = sm.createdAt, API.updatedAt = sm.updatedAt } diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index ebaf52f684e..e8919a65ed8 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -353,51 +353,6 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do result `shouldBe` Right Nothing - it "creates trial meeting for personal user" $ do - let now = UTCTime (fromGregorian 2026 1 1) 0 - gen = mkStdGen 42 - uid = Id $ read "00000000-0000-0000-0000-000000000001" - zUser = toLocalUnsafe (Domain "wire.com") uid - newMeeting = - API.NewMeeting - { title = fromJust $ checked "Personal Meeting", - startTime = addUTCTime 3600 now, - endTime = addUTCTime 7200 now, - recurrence = Nothing, - invitedEmails = [] - } - - result <- - runTestStack now gen Map.empty def $ - createMeeting zUser newMeeting - - fmap (.meeting.trial) result `shouldBe` Right True - - it "creates non-trial meeting for team user" $ do - let now = UTCTime (fromGregorian 2026 1 1) 0 - gen = mkStdGen 42 - uid = Id $ read "00000000-0000-0000-0000-000000000001" - zUser = toLocalUnsafe (Domain "wire.com") uid - teamId = Id $ read "00000000-0000-0000-0000-000000000100" - teamMember = mkTeamMember uid fullPermissions Nothing UserLegalHoldDisabled - teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ - def - newMeeting = - API.NewMeeting - { title = fromJust $ checked "Team Meeting", - startTime = addUTCTime 3600 now, - endTime = addUTCTime 7200 now, - recurrence = Nothing, - invitedEmails = [] - } - - result <- - runTestStack now gen (Map.singleton teamId [teamMember]) teamConfig $ - createMeeting zUser newMeeting - - fmap (.meeting.trial) result `shouldBe` Right False - describe "updateMeeting" $ do let now = UTCTime (fromGregorian 2026 1 1) 0 gen = mkStdGen 42 diff --git a/services/galley/src/Galley/API/Public/Meetings.hs b/services/galley/src/Galley/API/Public/Meetings.hs index 41aacbc8c0a..a38ca024753 100644 --- a/services/galley/src/Galley/API/Public/Meetings.hs +++ b/services/galley/src/Galley/API/Public/Meetings.hs @@ -24,10 +24,14 @@ import Wire.API.Routes.Public.Galley.Meetings meetingsAPI :: API MeetingsAPI GalleyEffects meetingsAPI = - mkNamedAPI @"create-meeting" Meetings.createMeeting + mkNamedAPI @"create-meeting@v15" Meetings.createMeeting + <@> mkNamedAPI @"create-meeting" Meetings.createMeeting + <@> mkNamedAPI @"update-meeting@v15" Meetings.updateMeeting <@> mkNamedAPI @"update-meeting" Meetings.updateMeeting <@> mkNamedAPI @"delete-meeting" Meetings.deleteMeeting + <@> mkNamedAPI @"get-meeting@v15" Meetings.getMeeting <@> mkNamedAPI @"get-meeting" Meetings.getMeeting + <@> mkNamedAPI @"list-meetings@v16" Meetings.listMeetings <@> mkNamedAPI @"list-meetings" Meetings.listMeetings <@> mkNamedAPI @"add-meeting-invitation" Meetings.addMeetingInvitation <@> mkNamedAPI @"remove-meeting-invitation" Meetings.removeMeetingInvitation From 35d8df6b11d916723022757514e6a6166bec13c4 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Sat, 25 Jul 2026 14:19:17 +0200 Subject: [PATCH 033/113] WPB-26771: Deprecate meetingsPremium endpoints at API v17 (#5364) --- .../wpb-26771-meetings-premium.md | 3 +- .../wpb-26771-meetings-premium-endpoint.md | 8 ++++ .../src/developer/reference/config-options.md | 16 ++++--- integration/test/Test/FeatureFlags.hs | 11 ++++- .../test/Test/FeatureFlags/MeetingPremium.hs | 43 ++++++++++++++++++- .../src/Wire/API/Routes/Internal/Galley.hs | 2 +- .../Wire/API/Routes/Public/Galley/Feature.hs | 4 +- libs/wire-api/src/Wire/API/VersionInfo.hs | 12 ++++++ .../galley/src/Galley/API/Public/Feature.hs | 3 +- 9 files changed, 88 insertions(+), 14 deletions(-) create mode 100644 changelog.d/1-api-changes/wpb-26771-meetings-premium-endpoint.md diff --git a/changelog.d/0-release-notes/wpb-26771-meetings-premium.md b/changelog.d/0-release-notes/wpb-26771-meetings-premium.md index b3a13dce511..003c60b205a 100644 --- a/changelog.d/0-release-notes/wpb-26771-meetings-premium.md +++ b/changelog.d/0-release-notes/wpb-26771-meetings-premium.md @@ -5,4 +5,5 @@ `charts/wire-server`. The flag's data type and its public/internal HTTP endpoints are retained for backward compatibility but have no behavioural effect; any Helm overrides for `meetingsPremium` are now ignored and can be - removed. The flag is scheduled for removal in a future release. + removed. The public/internal HTTP endpoints now return 404 at API version v17 + and remain available through v16; the flag type remains deprecated. The aggregate `GET /feature-configs` and `GET /teams/:tid/features` endpoints continue to include `meetingsPremium` at all API versions, including v17. diff --git a/changelog.d/1-api-changes/wpb-26771-meetings-premium-endpoint.md b/changelog.d/1-api-changes/wpb-26771-meetings-premium-endpoint.md new file mode 100644 index 00000000000..b8cb1c77871 --- /dev/null +++ b/changelog.d/1-api-changes/wpb-26771-meetings-premium-endpoint.md @@ -0,0 +1,8 @@ +The `meetingsPremium` team feature endpoints are deprecated and return 404 for +clients on API version v17: the public `GET`/`PUT /teams/:tid/features/meetingsPremium` +and the internal legacy lock `PUT /i/teams/:tid/features/meetingsPremium/(un)?locked`. +They remain available through v16. The flag has had no behavioural effect since +WPB-26771 (team meetings are always non-trial). The aggregate endpoints +`GET /feature-configs` and `GET /teams/:tid/features` are unaffected and continue +to include `meetingsPremium` at all API versions: the aggregate feature list is +version-agnostic, like other version-gated features such as MLS. (WPB-26771) diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index 947c7619912..cdd08200337 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -286,15 +286,19 @@ for `smtp.passwordFile`. > **Deprecated (WPB-26771).** The `meetingsPremium` feature flag no longer > affects meeting behaviour. Team meetings are always non-trial regardless of -> this flag's value. The flag, its data type and its public/internal endpoints -> are retained for backward compatibility and are scheduled for removal in a -> future release. +> this flag's value. The flag and its data type are retained for backward +> compatibility. The flag now defaults to **enabled and locked** and the Helm configuration override has been removed (operators can no longer change it via Helm). The -`MeetingsPremiumConfig` type carries a `DEPRECATED` pragma. Existing -`GET/PUT /teams/:tid/features/meetingsPremium` and internal lock-status -endpoints remain available but have no behavioural effect. +`MeetingsPremiumConfig` type carries a `DEPRECATED` pragma. The public +`GET/PUT /teams/:tid/features/meetingsPremium` endpoints and the internal +lock-status endpoints have no behavioural effect and now return 404 at API +version v17; they remain available through v16. + +The aggregate list endpoints (`GET /feature-configs`, +`GET /teams/:tid/features`) continue to include `meetingsPremium` at all API +versions, including v17. ### Background Effects diff --git a/integration/test/Test/FeatureFlags.hs b/integration/test/Test/FeatureFlags.hs index 49410110203..7dc7c59f5b6 100644 --- a/integration/test/Test/FeatureFlags.hs +++ b/integration/test/Test/FeatureFlags.hs @@ -91,5 +91,12 @@ testNonMemberAccess :: (HasCallStack) => Feature -> App () testNonMemberAccess (Feature featureName) = do (_, tid, _) <- createTeam OwnDomain 0 nonMember <- randomUser OwnDomain def - Public.getTeamFeature nonMember tid featureName - >>= assertForbidden + -- meetingsPremium's public per-feature GET is version-gated at v17 + -- (WPB-26771): it 404s there. Hit it at v16 so the non-member-access + -- authz check (403 no-team-member) is still exercised. + let getFeature = Public.getTeamFeature nonMember tid featureName + resp <- + if featureName == "meetingsPremium" + then withAPIVersion 16 getFeature + else getFeature + assertForbidden resp diff --git a/integration/test/Test/FeatureFlags/MeetingPremium.hs b/integration/test/Test/FeatureFlags/MeetingPremium.hs index ecbd2098684..df23d58b9d0 100644 --- a/integration/test/Test/FeatureFlags/MeetingPremium.hs +++ b/integration/test/Test/FeatureFlags/MeetingPremium.hs @@ -17,14 +17,53 @@ module Test.FeatureFlags.MeetingPremium where +import SetupHelpers (createTeam) import Test.FeatureFlags.Util import Testlib.Prelude testPatchMeetingPremium :: (HasCallStack) => App () -testPatchMeetingPremium = checkPatch OwnDomain "meetingsPremium" disabledLocked +testPatchMeetingPremium = withAPIVersion 16 $ checkPatch OwnDomain "meetingsPremium" disabledLocked testMeetingPremium :: (HasCallStack) => APIAccess -> App () testMeetingPremium access = - mkFeatureTests "meetingsPremium" + withAPIVersion 16 + $ mkFeatureTests "meetingsPremium" & addUpdate enabled & runFeatureTests OwnDomain access + +-- | WPB-26771: the public meetingsPremium endpoints are gated at v17 (404) +-- while remaining available through v16. Only the v16 GET is asserted here: +-- v16 PUT success is covered by 'testMeetingPremium' (whose runFeatureTests +-- unlocks the feature first), and a public PUT in this test would 409 +-- feature-locked against the default enabled+locked state. +testMeetingPremiumRemovedAtV17 :: (HasCallStack) => App () +testMeetingPremiumRemovedAtV17 = do + (owner, tid, _) <- createTeam OwnDomain 0 + let p = joinHttpPath ["teams", tid, "features", "meetingsPremium"] + body = object ["status" .= "enabled", "lockStatus" .= "locked"] + bindResponse (baseRequest owner Galley (ExplicitVersion 17) p >>= submit "GET") $ \resp -> do + resp.status `shouldMatchInt` 404 + bindResponse (baseRequest owner Galley (ExplicitVersion 17) p <&> addJSON body >>= submit "PUT") $ \resp -> do + resp.status `shouldMatchInt` 404 + bindResponse (baseRequest owner Galley (ExplicitVersion 16) p >>= submit "GET") $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "status" `shouldMatch` "enabled" + resp.json %. "lockStatus" `shouldMatch` "locked" + +-- | WPB-26771: the aggregate list endpoints 'GET /feature-configs' and +-- 'GET /teams/:tid/features' are version-agnostic — like 'From'-gated features +-- (e.g. MLS), they include every feature at every API version. Even though the +-- dedicated meetingsPremium endpoints 404 at v17, both list endpoints keep +-- returning the (default enabled+locked) meetingsPremium entry. This test +-- locks that behaviour in. +testMeetingPremiumListedAtV17 :: (HasCallStack) => App () +testMeetingPremiumListedAtV17 = do + (owner, tid, _) <- createTeam OwnDomain 0 + let assertMeetingPremium resp = do + resp.status `shouldMatchInt` 200 + mp <- resp.json %. "meetingsPremium" + mp %. "status" `shouldMatch` "enabled" + mp %. "lockStatus" `shouldMatch` "locked" + teamFeatures = joinHttpPath ["teams", tid, "features"] + bindResponse (baseRequest owner Galley (ExplicitVersion 17) "/feature-configs" >>= submit "GET") assertMeetingPremium + bindResponse (baseRequest owner Galley (ExplicitVersion 17) teamFeatures >>= submit "GET") assertMeetingPremium diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs index ea46e17ff34..5cee430ea52 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs @@ -99,7 +99,7 @@ type IFeatureAPI = :<|> IFeatureStatusLockStatusPut SimplifiedUserConnectionRequestQRCodeConfig :<|> IFeatureStatusLockStatusPut StealthUsersConfig :<|> IFeatureStatusLockStatusPut MeetingsConfig - :<|> IFeatureStatusLockStatusPut MeetingsPremiumConfig + :<|> Until 'V17 ::> IFeatureStatusLockStatusPut MeetingsPremiumConfig :<|> IFeatureStatusLockStatusPut BackgroundEffectsConfig -- all feature configs :<|> Named diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs index 5b38473c306..0f7d9511a1e 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs @@ -22,6 +22,7 @@ import Data.Id import GHC.TypeLits import Servant import Servant.OpenApi.Internal.Orphans () +import Wire.API.Deprecated import Wire.API.Error import Wire.API.Error.Galley import Wire.API.OAuth @@ -83,7 +84,8 @@ type FeatureAPI = :<|> FeatureAPIGet StealthUsersConfig :<|> FeatureAPIGet CellsInternalConfig :<|> FeatureAPIGetPut MeetingsConfig - :<|> FeatureAPIGetPut MeetingsPremiumConfig + :<|> Deprecated ::> Until 'V17 ::> FeatureAPIGet MeetingsPremiumConfig + :<|> Deprecated ::> Until 'V17 ::> FeatureAPIPut MeetingsPremiumConfig :<|> FeatureAPIGetPut BackgroundEffectsConfig type VersionedFeatureAPIPut named reqBodyVersion cfg = diff --git a/libs/wire-api/src/Wire/API/VersionInfo.hs b/libs/wire-api/src/Wire/API/VersionInfo.hs index 925e489859f..45cdcda45f5 100644 --- a/libs/wire-api/src/Wire/API/VersionInfo.hs +++ b/libs/wire-api/src/Wire/API/VersionInfo.hs @@ -130,6 +130,13 @@ instance instance (RoutesToPaths api) => RoutesToPaths (Until v :> api) where getRoutes = getRoutes @api +-- | 'Until' is transparent for OpenAPI generation: version gating is a runtime +-- concern, and a non-version-specific doc (e.g. the internal API) renders the +-- inner endpoint. Per-version docs go through 'SpecialiseToVersion', which +-- strips 'Until'\/'From' and never reaches this instance. +instance (HasOpenApi api) => HasOpenApi (Until v :> api) where + toOpenApi _ = toOpenApi (Proxy @api) + instance ( SingI n, Ord (Demote v), @@ -172,6 +179,11 @@ instance instance (RoutesToPaths api) => RoutesToPaths (From v :> api) where getRoutes = getRoutes @api +-- | Same as the 'Until' instance above: a doc no-op for non-version-specific +-- docs (e.g. the internal API). Per-version docs specialise it away. +instance (HasOpenApi api) => HasOpenApi (From v :> api) where + toOpenApi _ = toOpenApi (Proxy @api) + instance (Enum v, HasServer api ctx) => HasServer (APIVersion (v :: Type) :> api) ctx where type ServerT (APIVersion v :> api) m = v -> ServerT api m diff --git a/services/galley/src/Galley/API/Public/Feature.hs b/services/galley/src/Galley/API/Public/Feature.hs index cbb62ac2233..29613cc0267 100644 --- a/services/galley/src/Galley/API/Public/Feature.hs +++ b/services/galley/src/Galley/API/Public/Feature.hs @@ -84,7 +84,8 @@ featureAPI = <@> mkNamedAPI @'("get", StealthUsersConfig) getFeature <@> mkNamedAPI @'("get", CellsInternalConfig) getFeature <@> featureAPIGetPut @MeetingsConfig - <@> featureAPIGetPut @MeetingsPremiumConfig + <@> mkNamedAPI @'("get", MeetingsPremiumConfig) getFeature + <@> mkNamedAPI @'("put", MeetingsPremiumConfig) setFeature <@> featureAPIGetPut @BackgroundEffectsConfig deprecatedFeatureConfigAPI :: API DeprecatedFeatureAPI GalleyEffects From 3cb74c433d07689d8c25f5a7894d4fc2053e2725 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Mon, 27 Jul 2026 16:15:20 +0200 Subject: [PATCH 034/113] Various improvements to cleanup PR 5343 (#5354) Small changes that were left-overs from the mentioned PR's review. --- ...a-external-apps_-in-_get-_teams__tid_apps_ | 2 +- libs/wire-subsystems/src/Wire/AppSubsystem.hs | 7 ++- .../src/Wire/AppSubsystem/Interpreter.hs | 6 +-- .../src/Wire/UserSubsystem/Interpreter.hs | 2 +- .../Wire/UserSubsystem/InterpreterSpec.hs | 45 +++++++++++++++++-- services/brig/src/Brig/API/Internal.hs | 2 +- 6 files changed, 51 insertions(+), 13 deletions(-) diff --git a/changelog.d/3-bug-fixes/WPB-27227-include-collaborating-apps-_aka-external-apps_-in-_get-_teams__tid_apps_ b/changelog.d/3-bug-fixes/WPB-27227-include-collaborating-apps-_aka-external-apps_-in-_get-_teams__tid_apps_ index 0d49465fecc..1a63e63613b 100644 --- a/changelog.d/3-bug-fixes/WPB-27227-include-collaborating-apps-_aka-external-apps_-in-_get-_teams__tid_apps_ +++ b/changelog.d/3-bug-fixes/WPB-27227-include-collaborating-apps-_aka-external-apps_-in-_get-_teams__tid_apps_ @@ -1 +1 @@ -Include collaborating apps (aka external apps) in "GET /teams/:tid/apps". ([drive-by] move access control for UserSubsystem.GetLocalAppProfiles from brig into wire-subsystems.) +Include collaborating apps (aka external apps) in "GET /teams/:tid/apps". (Drive-by improvement: move access control for UserSubsystem.GetLocalAppProfiles from brig into wire-subsystems.) diff --git a/libs/wire-subsystems/src/Wire/AppSubsystem.hs b/libs/wire-subsystems/src/Wire/AppSubsystem.hs index ed16ba5bfa7..0a41bb91fbb 100644 --- a/libs/wire-subsystems/src/Wire/AppSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/AppSubsystem.hs @@ -69,10 +69,9 @@ data AppSubsystem m a where UserId -> Maybe PlainTextPassword6 -> AppSubsystem m (Either RetryAfter SomeUserToken) - DeleteApp :: - TeamId -> - UserId -> - AppSubsystem m () + -- | Delete app. This is called when deleting team members. It + -- does not check authentication. + InternalDeleteApp :: TeamId -> UserId -> AppSubsystem m () makeSem ''AppSubsystem diff --git a/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs index 513deff6bfa..33cf8fb521c 100644 --- a/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs @@ -83,7 +83,7 @@ runAppSubsystem runUser runAuth = GetApps lusr tid -> getAppsImpl lusr tid UpdateApp lusr tid uid put -> updateAppImpl lusr tid uid put RefreshAppCookie lusr tid appId password -> runError $ refreshAppCookieImpl lusr tid appId password - DeleteApp tid appId -> deleteAppImpl tid appId + InternalDeleteApp tid appId -> internalDeleteAppImpl tid appId createAppImpl :: ( Member UserStore r, @@ -287,10 +287,10 @@ appNewStoredUser creator new = do defAppSupportedProtocols :: Set BaseProtocolTag defAppSupportedProtocols = Set.singleton BaseProtocolMLSTag -deleteAppImpl :: +internalDeleteAppImpl :: (Member AppStore r) => TeamId -> UserId -> Sem r () -deleteAppImpl teamId appId = +internalDeleteAppImpl teamId appId = Store.deleteApp appId teamId diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index d66a3d87b40..d85eb1fbe26 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -450,7 +450,7 @@ getLocalAppProfilesImpl self tid = do throw UserSubsystemProfileNotFound let ltid = qualifyAs self tid - apps :: [AppStore.StoredApp] <- AppStore.getApps tid + apps <- AppStore.getApps tid profiles <- getUserProfilesLocalPart Nothing (ltid $> map (.id) apps) let appsMap :: Map UserId AppStore.StoredApp appsMap = Map.fromList ((\app -> (app.id, app)) <$> apps) diff --git a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs index 6fd35962c64..28c120822f4 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs @@ -1192,10 +1192,49 @@ spec = describe "UserSubsystem.Interpreter" do ] result :: ([UserId], [UserId]) = runNoFederationStack localBackend teams config $ do - let f tid caller = + let getAppId tid caller = qUnqualified . (.profileQualifiedId) <$$> getLocalAppProfiles caller tid in (,) - <$> f teamAId teamAOwnerId - <*> f teamBId teamBOwnerId + <$> getAppId teamAId teamAOwnerId + <*> getAppId teamBId teamBOwnerId in result === ([appUser.id], [appUser.id]) + + prop "denies access when the caller's own team is not the requested team" . withMaxSuccess 1 $ + \(NotPendingStoredUser caller_) + (NotPendingStoredUser appUser_) + (callerTeamId :: TeamId) + (targetTeamId :: TeamId) + config -> + callerTeamId /= targetTeamId ==> + let localDomain = Domain "localdomain" + caller = caller_ {teamId = Just callerTeamId} :: StoredUser + callerId = toLocalUnsafe localDomain caller.id + appUser = appUser_ {userType = Just UserTypeApp, teamId = Just targetTeamId} :: StoredUser + storedApp = + AppStore.StoredApp + { id = appUser.id, + teamId = targetTeamId, + meta = mempty, + category = Category "other", + description = unsafeRange "test app", + creator = appUser.id + } + localBackend = + def + { users = [caller, appUser], + apps = [storedApp] + } + teams = + Map.fromList + [ ( callerTeamId, + [mkTeamMember caller.id fullPermissions Nothing defUserLegalHoldStatus] + ), + ( targetTeamId, + [mkTeamMember appUser.id fullPermissions Nothing defUserLegalHoldStatus] + ) + ] + result :: Either UserSubsystemError [UserProfile] = + runNoFederationStackUserSubsystemErrorEither localBackend teams config $ + getLocalAppProfiles callerId targetTeamId + in result === Left UserSubsystemProfileNotFound diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 1a1a68f1bef..ae535479e9a 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -1068,7 +1068,7 @@ deleteGroupManagedInternalH tid gid managedBy = do pure NoContent deleteAppH :: (Member AppSubsystem r) => TeamId -> UserId -> Handler r NoContent -deleteAppH tid uid = lift . liftSem $ AppSubsystem.deleteApp tid uid >> pure NoContent +deleteAppH tid uid = lift . liftSem $ AppSubsystem.internalDeleteApp tid uid >> pure NoContent getAppIdsH :: (Member AppStore r) => TeamId -> Handler r [UserId] getAppIdsH tid = lift . liftSem $ map (.id) <$> AppStore.getApps tid From cab9311bfaf4ddbcfad5963cf5a02c6be34b0532 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Mon, 27 Jul 2026 19:11:21 +0200 Subject: [PATCH 035/113] WPB-27465: allow editing ongoing (already-started) meetings (#5373) --- .../wpb-27465-meeting-update-ongoing | 1 + .../src/Wire/MeetingsSubsystem/Interpreter.hs | 9 ++- .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 56 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 changelog.d/1-api-changes/wpb-27465-meeting-update-ongoing diff --git a/changelog.d/1-api-changes/wpb-27465-meeting-update-ongoing b/changelog.d/1-api-changes/wpb-27465-meeting-update-ongoing new file mode 100644 index 00000000000..5eed2b98024 --- /dev/null +++ b/changelog.d/1-api-changes/wpb-27465-meeting-update-ongoing @@ -0,0 +1 @@ +PUT /meetings/{domain}/{id} can now edit a meeting that has already started (an ongoing meeting). The start-time-not-in-the-past validation added by WPB-26773 previously rejected any update whose start_time was in the past, which also blocked legitimate edits to ongoing meetings — whose start time is naturally in the past; the check now applies only to meetings that have not started yet. Creating a meeting with a past start time, and moving an upcoming meeting's start time into the past, remain rejected (WPB-27465). diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs index cee81b3b1ad..4dbc07b2dfd 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs @@ -237,9 +237,14 @@ updateMeetingImpl zUser meetingId update validityPeriod = do when (fromMaybe meeting.startTime update.startTime >= fromMaybe meeting.endTime update.endTime) $ lift $ throw InvalidTimes - -- Validate that the updated start time (if provided) is not in the past + -- Reject moving the start time into the past, but only while the meeting is + -- still upcoming. A meeting that has already started may still be edited -- + -- its start time is naturally in the past -- so clients can keep updating an + -- ongoing meeting (title, end time, recurrence, or even the start time) + -- without being blocked by the past-start check (WPB-27465). + let pastCutoff = addUTCTime (negate startTimeTolerance) now for_ update.startTime $ \t -> - when (t < addUTCTime (negate startTimeTolerance) now) $ + when (meeting.startTime >= pastCutoff && t < pastCutoff) $ lift $ throw InvalidTimes diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index e8919a65ed8..b0e60869a12 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -428,6 +428,62 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do result `shouldBe` Left InvalidTimes + it "allows editing an already-started (ongoing) meeting, including its start time" $ do + let ongoingMeeting = + API.NewMeeting + { title = fromJust $ checked "Ongoing Meeting", + startTime = addUTCTime 100 now, + endTime = addUTCTime 7200 now, + recurrence = Nothing, + invitedEmails = [] + } + result <- + runTestStack now gen Map.empty teamConfig $ do + meeting <- createMeeting zUser1 ongoingMeeting + -- Advance the clock 3000s: startTime (now+100s) is now in the past, so + -- the meeting has started. It stays editable because isAlive is + -- endTime-based and endTime (now+7200s) is still well past the + -- alive-cutoff (now+3000s-3600s = now-600s). + passTime 3000 + let update = + API.UpdateMeeting + { -- Original start time, which is now in the past. + startTime = Just (addUTCTime 100 now), + endTime = Nothing, + title = Just (unsafeRange "Edited While Ongoing"), + recurrence = Nothing + } + updateMeeting zUser1 meeting.meeting.id update + case result of + Left err -> + fail $ "Expected the ongoing meeting to be editable, got: " <> show err + Right Nothing -> fail "Expected the update to be applied" + Right (Just updated) -> + updated.meeting.title `shouldBe` unsafeRange "Edited While Ongoing" + + it "still throws InvalidTimes when editing an ongoing meeting to startTime >= endTime" $ do + let ongoingMeeting = + API.NewMeeting + { title = fromJust $ checked "Ongoing Meeting", + startTime = addUTCTime 100 now, + endTime = addUTCTime 7200 now, + recurrence = Nothing, + invitedEmails = [] + } + result <- + runTestStack now gen Map.empty teamConfig $ do + meeting <- createMeeting zUser1 ongoingMeeting + passTime 3000 + let update = + API.UpdateMeeting + { startTime = Just (addUTCTime 8000 now), + endTime = Nothing, + title = Nothing, + recurrence = Nothing + } + updateMeeting zUser1 meeting.meeting.id update + result `shouldBe` Left InvalidTimes + it "returns Nothing for expired meeting" $ do let newMeeting = API.NewMeeting From 8a5e78fc53cf4beed5bfd9f5e7e027b6da1eb92b Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Tue, 28 Jul 2026 09:44:51 +0200 Subject: [PATCH 036/113] WPB-26489 improve connection pool management for background jobs (#5375) --- changelog.d/0-release-notes/WPB-26489 | 8 +-- .../src/developer/reference/config-options.md | 34 ++++++++---- flake.lock | 8 +-- flake.nix | 2 +- libs/extended/src/Hasql/Pool/Extended.hs | 13 ----- libs/wire-api/default.nix | 2 + libs/wire-api/src/Wire/API/Jobs.hs | 5 +- libs/wire-api/wire-api.cabal | 1 + libs/wire-subsystems/default.nix | 3 ++ .../src/Wire/JobSubsystem/ArbiterAdapter.hs | 22 ++++---- .../src/Wire/JobSubsystem/Interpreter.hs | 14 ++--- .../src/Wire/JobSubsystem/Migrations.hs | 18 ++++++- libs/wire-subsystems/wire-subsystems.cabal | 1 + nix/haskell-pins.nix | 1 + .../src/Wire/BackgroundWorker/Env.hs | 3 +- .../src/Wire/BackgroundWorker/Workers.hs | 52 ++++--------------- services/galley/src/Galley/Run.hs | 6 +-- 17 files changed, 98 insertions(+), 95 deletions(-) diff --git a/changelog.d/0-release-notes/WPB-26489 b/changelog.d/0-release-notes/WPB-26489 index 9eb6ee25b5b..b800b5205e9 100644 --- a/changelog.d/0-release-notes/WPB-26489 +++ b/changelog.d/0-release-notes/WPB-26489 @@ -1,5 +1,7 @@ -Background-worker now runs some new jobs. The background-worker configuration exposes the job dispatcher, worker, retry, shutdown, and reaper settings under `jobs`; all settings default to the current behavior. `jobs.workerThreads` defaults to `1`, so no operator action is required when the default parallelism is sufficient. -Jobs currently use separate `meetings` and `conversations` queues, with one worker pool assigned to each queue. -Each background-worker instance uses one additional PostgreSQL connection for job coordination; increasing `workerThreads` does not increase the connection count. +Background-worker now runs additional jobs and has new settings. The `jobs` settings configure the dispatcher, worker, retry, shutdown, and reaper behavior, with defaults matching the existing behavior. The initial queues are `meetings` and `conversations`, with one worker pool assigned to each queue. + +Operators should size the background-worker PostgreSQL pool and PostgreSQL `max_connections` for this workload and the transient connection used while acquiring the migration lock; that connection is closed after migrations complete. `jobs.workerThreads` defaults to `1`. + +Both worker pools use the same PostgreSQL pool. Connections are borrowed for active job transactions and short-lived worker operations, rather than being reserved permanently per pool. LISTEN/NOTIFY is disabled, so the job runner does not open an additional listener connection. The Helm chart sets `background-worker.terminationGracePeriodSeconds` to `40`, providing a margin over the default `jobs.gracefulShutdownTimeout` of `30s`. Adjust both settings together if changing the shutdown timeout. diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index cdd08200337..1cf3a20f1b0 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -2267,6 +2267,20 @@ contains meeting cleanup jobs, and `conversations`, which contains adminless reminder and deletion jobs. Each queue has its own Arbiter worker pool. The `workerThreads` value applies independently to both pools. +Both worker pools share the configured `postgresqlPool`; no connection is +reserved permanently for an individual queue or worker pool. An active +transactional job temporarily holds one connection, so concurrent jobs can +borrow multiple connections from the shared pool. Short-lived dispatcher, +heartbeat, and reaper operations also borrow connections as needed. For +example, with two queues and `workerThreads: 3`, up to six active job +transactions may need connections concurrently, in addition to these internal +operations. Size `postgresqlPool.size` and PostgreSQL `max_connections` for +the expected concurrency. + +The job runner uses polling rather than LISTEN/NOTIFY. It therefore does not +open a separate listener connection; new jobs are discovered according to +`jobs.pollInterval`. + `backgroundJobs` and `jobs` configure different job systems. The `backgroundJobs` consumer receives immediate user-group synchronization jobs from RabbitMQ and controls their in-process concurrency, timeout, and retry @@ -2283,17 +2297,15 @@ federationDomain: example.org ### Job runner PostgreSQL connections -Each `background-worker` instance that runs Arbiter jobs uses one additional -PostgreSQL connection for Arbiter scheduler and notification coordination. This -connection is in addition to the connections configured by `postgresqlPool`, -and should be included when sizing PostgreSQL's `max_connections` and the -service's connection budget. +Background-worker runs Arbiter jobs through its configured PostgreSQL pool. +Choose the pool size and `jobs.workerThreads` to provide sufficient capacity for +the expected job workload, and size PostgreSQL's `max_connections` accordingly. -`jobs.workerThreads` controls how many jobs may be processed -in parallel; it does not allocate one PostgreSQL connection per thread. The -threads share the job worker's database resources, so increasing the -thread count increases possible job and database workload, but not the number -of connections opened by the job worker. +At startup, each service that runs the Arbiter migrations briefly opens a +separate PostgreSQL connection to acquire the migration advisory lock. Include +this transient connection in the PostgreSQL connection budget and startup +headroom. It remains open for the migration and related index setup, then is +released and closed after the advisory lock is released. The `migrationOptions.timeout` setting limits how long a single migration attempt may run after it has acquired the migration lock. If the timeout is @@ -2319,3 +2331,5 @@ Notes - `jobs` controls the Arbiter dispatcher, worker, retry, shutdown, and reaper settings. All fields default to the values shown above. - `jobs.pollInterval` controls how often the background worker wakes up to check for due jobs. - `jobs.workerThreads` controls the number of worker threads in each job queue. The default is `1`; increasing it allows jobs in that queue to run in parallel when their group keys permit it. +- Both job queues share the same PostgreSQL pool. Increasing `jobs.workerThreads` can increase the number of connections needed when more jobs run concurrently, but it does not create a permanently dedicated connection per thread or queue. +- The job runner is poll-only and does not require an additional PostgreSQL listener connection. diff --git a/flake.lock b/flake.lock index ac9b69a8c9a..bd8e74f6d3a 100644 --- a/flake.lock +++ b/flake.lock @@ -19,17 +19,17 @@ "arbiter": { "flake": false, "locked": { - "lastModified": 1783611005, - "narHash": "sha256-zXAL4NEMhlgSZC2CpmOca67qK6dkUMXVRGTEzf1sMZs=", + "lastModified": 1785161366, + "narHash": "sha256-qNZX0LWa+78C3j37fPYaJ0VZOGnkmAEo1Qpa0J6O6gY=", "owner": "velveteer", "repo": "arbiter", - "rev": "296034ea3a15b5c10f42cc0ea46d2dfc48e9493f", + "rev": "b9c57eb1f8277d97616aa449bea471fe9ce14eda", "type": "github" }, "original": { "owner": "velveteer", "repo": "arbiter", - "rev": "296034ea3a15b5c10f42cc0ea46d2dfc48e9493f", + "rev": "b9c57eb1f8277d97616aa449bea471fe9ce14eda", "type": "github" } }, diff --git a/flake.nix b/flake.nix index 34eab2ffd7c..09cc084bba4 100644 --- a/flake.nix +++ b/flake.nix @@ -110,7 +110,7 @@ }; arbiter = { - url = "github:velveteer/arbiter?rev=296034ea3a15b5c10f42cc0ea46d2dfc48e9493f"; + url = "github:velveteer/arbiter?rev=b9c57eb1f8277d97616aa449bea471fe9ce14eda"; flake = false; }; }; diff --git a/libs/extended/src/Hasql/Pool/Extended.hs b/libs/extended/src/Hasql/Pool/Extended.hs index 31c6bf67cab..cebbdca0442 100644 --- a/libs/extended/src/Hasql/Pool/Extended.hs +++ b/libs/extended/src/Hasql/Pool/Extended.hs @@ -18,9 +18,7 @@ module Hasql.Pool.Extended where import Data.Aeson -import Data.Map qualified as Map import Data.Misc -import Data.Secret (SecretText, secretText) import Hasql.Connection qualified import Hasql.Connection.Settings qualified as HasqlConnSettings import Hasql.Pool qualified as HasqlPool @@ -48,17 +46,6 @@ instance FromJSON PoolConfig where <*> o .: "acquisitionTimeout" <*> o .: "idlenessTimeout" --- | Render a PostgreSQL connection string in libpq key-value format. --- --- Passwords from the optional secret file are inserted into the key-value map --- before rendering. The result is wrapped because it may contain the password. -postgresqlConnectionStringWithPassword :: Map Text Text -> Maybe FilePathSecrets -> IO SecretText -postgresqlConnectionStringWithPassword pgConfig mFpSecrets = do - mPw <- for mFpSecrets initCredentials - let pgConfig' = maybe pgConfig (\pw -> Map.insert "password" pw pgConfig) mPw - pure . secretText . PostgresqlConnectionString.toKeyValueString $ - PostgresqlConnectionString.fromKeyValueParams pgConfig' - data HasqlPoolMetrics = HasqlPoolMetrics { readyForUseGauge :: Gauge, inUseGauge :: Gauge, diff --git a/libs/wire-api/default.nix b/libs/wire-api/default.nix index f70172b2fd3..1c9eaf75c20 100644 --- a/libs/wire-api/default.nix +++ b/libs/wire-api/default.nix @@ -8,6 +8,7 @@ , aeson-pretty , aeson-qq , amqp +, arbiter-core , async , attoparsec , barbies @@ -132,6 +133,7 @@ mkDerivation { libraryHaskellDepends = [ aeson amqp + arbiter-core attoparsec barbies base diff --git a/libs/wire-api/src/Wire/API/Jobs.hs b/libs/wire-api/src/Wire/API/Jobs.hs index 218e097f8ad..f60d60ca74f 100644 --- a/libs/wire-api/src/Wire/API/Jobs.hs +++ b/libs/wire-api/src/Wire/API/Jobs.hs @@ -23,6 +23,7 @@ module Wire.API.Jobs where +import Arbiter.Core.QueueRegistry (Queue) import Control.Arrow ((&&&)) import Control.Lens (makePrisms) import Data.Aeson (FromJSON, ToJSON) @@ -248,6 +249,6 @@ instance Arbitrary ConversationsJobPayload where -- | Registry for the jobs we expose via Arbiter. type JobRegistry = - '[ '(MeetingsQueueName, MeetingsJobPayload), - '(ConversationsQueueName, ConversationsJobPayload) + '[ Queue MeetingsQueueName MeetingsJobPayload, + Queue ConversationsQueueName ConversationsJobPayload ] diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index d7941674ad1..6d607ffedf1 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -285,6 +285,7 @@ library build-depends: , aeson >=2.0.1.0 , amqp + , arbiter-core , attoparsec >=0.10 , barbies , base >=4 && <5 diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix index c522d4489a2..cca2ff50828 100644 --- a/libs/wire-subsystems/default.nix +++ b/libs/wire-subsystems/default.nix @@ -91,6 +91,7 @@ , polysemy-plugin , polysemy-time , polysemy-wire-zoo +, postgresql-connection-string , postgresql-error-codes , profunctors , prometheus-client @@ -232,6 +233,7 @@ mkDerivation { polysemy-plugin polysemy-time polysemy-wire-zoo + postgresql-connection-string postgresql-error-codes profunctors prometheus-client @@ -364,6 +366,7 @@ mkDerivation { polysemy-plugin polysemy-time polysemy-wire-zoo + postgresql-connection-string profunctors prometheus-client proto-lens diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs index 69a33f35363..5c7ff2449e7 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs @@ -13,9 +13,9 @@ module Wire.JobSubsystem.ArbiterAdapter where import Arbiter.Core.Codec (Params, RowCodec) import Arbiter.Core.Exceptions (throwInternal) -import Arbiter.Core.HasArbiterSchema (HasArbiterSchema (..)) -import Arbiter.Core.MonadArbiter (MonadArbiter (..)) +import Arbiter.Core.MonadArbiter (MonadArbiter (..), Query (..)) import Arbiter.Core.QueueRegistry (JobPayloadRegistry) +import Arbiter.Core.Sql.Query (numberPlaceholders) import Arbiter.Hasql.Decode qualified as Decode import Arbiter.Hasql.Encode qualified as Encode import Control.Exception (mask, onException, try) @@ -66,23 +66,22 @@ newtype WireArbiter (registry :: JobPayloadRegistry) a = WireArbiter runWireArbiter :: WireArbiterEnv -> WireArbiter registry a -> IO a runWireArbiter env (WireArbiter action) = runReaderT action env -instance HasArbiterSchema (WireArbiter registry) registry where - getSchema = asks schemaName - instance MonadArbiter (WireArbiter registry) where + type RegistryOf (WireArbiter registry) = registry type Handler (WireArbiter registry) jobs result = HasqlConn.Connection -> jobs -> WireArbiter registry result + getSchema = asks schemaName - executeQuery sql params codec = do + executeQuery (Query sql params codec) = do env <- ask withConn env $ \conn -> runQueryStatement False conn sql params codec - executeQueryPrepared sql params codec = do + executeQueryPrepared (Query sql params codec) = do env <- ask withConn env $ \conn -> runQueryStatement True conn sql params codec - executeStatement sql params = do + executeStatement (Query sql params _) = do env <- ask withConn env $ \conn -> runExecStatement conn sql params @@ -102,6 +101,11 @@ instance MonadArbiter (WireArbiter registry) where Just conn -> handler conn jobs Nothing -> throwInternal "runHandlerWithConnection: no active connection" + -- Wire's shared pool is already used for all Arbiter database work. Keep + -- workers poll-only so they do not pin an additional PostgreSQL connection + -- for LISTEN/NOTIFY. + getListener = pure Nothing + withConn :: WireArbiterEnv -> (HasqlConn.Connection -> IO a) -> WireArbiter registry a withConn env f = case activeConn env of @@ -134,7 +138,7 @@ runQueryStatement prepare conn sql params codec = do let mk = if prepare then Statement.preparable else Statement.unpreparable stmt = mk - (Encode.convertPlaceholders sql) + (numberPlaceholders sql) (Encode.buildEncoder params) (Decode.hasqlRowDecoder codec) result <- HasqlConn.use conn (Session.statement () stmt) diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs index f84134a366d..44fbb6dc6cb 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -74,7 +74,7 @@ scheduleAdminlessSetupJob JobSubsystemConfig {..} lusr teamId = do { ArbiterCore.dedupKey = Just . ArbiterCore.IgnoreDuplicate $ adminlessSetupJobDedupKey teamId, ArbiterCore.maxAttempts = Just 3 } - embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @(WireArbiter JobRegistry) @JobRegistry @ConversationsJobPayload arbiterJob + embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @ConversationsJobPayload @(WireArbiter JobRegistry) arbiterJob scheduleAdminlessDeletionJob :: forall r. @@ -99,7 +99,7 @@ scheduleAdminlessDeletionJob JobSubsystemConfig {..} lusr teamId convId schedule ArbiterCore.dedupKey = Just . ArbiterCore.IgnoreDuplicate $ adminlessJobDedupKey "deletion" convId, ArbiterCore.maxAttempts = Just 3 } - embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @(WireArbiter JobRegistry) @JobRegistry @ConversationsJobPayload arbiterJob + embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @ConversationsJobPayload @(WireArbiter JobRegistry) arbiterJob scheduleAdminlessReminderJob :: forall r. @@ -126,7 +126,7 @@ scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId deletion ArbiterCore.dedupKey = Just . ArbiterCore.IgnoreDuplicate $ adminlessReminderJobDedupKey convId reminderTimeout, ArbiterCore.maxAttempts = Just 3 } - embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @(WireArbiter JobRegistry) @JobRegistry @ConversationsJobPayload arbiterJob + embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @ConversationsJobPayload @(WireArbiter JobRegistry) arbiterJob cancelAdminlessJobsForTeam :: forall r. @@ -144,9 +144,11 @@ cancelAdminlessJobsForTeam JobSubsystemConfig {..} teamId = do ArbiterCore.withDbTransaction $ do jobIds <- ArbiterCore.executeQuery - (adminlessJobsForTeamQuery schemaName conversationsQueueName) - [pval CText (idToText teamId)] - (col "id" CInt8) + ( ArbiterCore.Query + (adminlessJobsForTeamQuery schemaName conversationsQueueName) + [pval CText (idToText teamId)] + (col "id" CInt8) + ) unless (null jobIds) $ void $ ArbiterOperations.cancelJobsBatch schemaName conversationsQueueName jobIds diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs index 21bd096d637..c39ac05fc71 100644 --- a/libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs @@ -19,15 +19,17 @@ -- with this program. If not, see . module Wire.JobSubsystem.Migrations - ( runJobMigrations, + ( mkArbiterConnectionString, + runJobMigrations, ) where import Arbiter.Migrations qualified as ArbiterMigrations import Control.Exception (bracket, bracket_, throwIO) import Data.Hashable qualified as Hashable +import Data.Map qualified as Map import Data.Proxy (Proxy (..)) -import Data.Secret (SecretText, revealSecretText) +import Data.Secret (SecretText, revealSecretText, secretText) import Data.Text qualified as T import Data.Text.Encoding qualified as Text import Hasql.Connection qualified as HasqlConnection @@ -38,10 +40,21 @@ import Hasql.Session qualified as HasqlSession import Hasql.Statement qualified as HasqlStatement import Hasql.TH import Imports +import PostgresqlConnectionString qualified import System.IO.Error (userError) import System.Timeout (timeout) +import Util.Options (FilePathSecrets, initCredentials) import Wire.API.Jobs (JobRegistry, conversationsQueueName) +-- | Build the secret-bearing connection string used by the Arbiter migration +-- lock and migration runner. +mkArbiterConnectionString :: Map Text Text -> Maybe FilePathSecrets -> IO SecretText +mkArbiterConnectionString pgConfig mFpSecrets = do + mPw <- for mFpSecrets initCredentials + let pgConfig' = maybe pgConfig (\pw -> Map.insert "password" pw pgConfig) mPw + pure . secretText . PostgresqlConnectionString.toKeyValueString $ + PostgresqlConnectionString.fromKeyValueParams pgConfig' + -- | Apply all migrations for the job registry before constructing any worker -- pools or accepting jobs. runJobMigrations :: SecretText -> Text -> IO () @@ -93,6 +106,7 @@ runJobMigrations connStr schemaName = -- | Serialize Arbiter schema migrations across all service instances that can -- schedule or execute jobs. The lock is held on the same dedicated connection -- for the whole migration because PostgreSQL advisory locks are session-scoped. +-- The connection is released and closed after the lock is released. withArbiterMigrationLock :: SecretText -> Text -> (HasqlConnection.Connection -> IO a) -> IO a withArbiterMigrationLock connStr schemaName action = do bracket acquireConnection HasqlConnection.release $ \lockConnection -> do diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 49e7c16e1f7..c25b9c9e87f 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -163,6 +163,7 @@ common common-all , polysemy-plugin , polysemy-time , polysemy-wire-zoo + , postgresql-connection-string , profunctors , prometheus-client , proto-lens diff --git a/nix/haskell-pins.nix b/nix/haskell-pins.nix index 07b0f2c3fa3..13ebf988279 100644 --- a/nix/haskell-pins.nix +++ b/nix/haskell-pins.nix @@ -67,6 +67,7 @@ let arbiter-simple = "arbiter-simple"; arbiter-test-common = "arbiter-test-common"; arbiter-worker = "arbiter-worker"; + arbiter-worker-testkit = "arbiter-worker-testkit"; }; }; diff --git a/services/background-worker/src/Wire/BackgroundWorker/Env.hs b/services/background-worker/src/Wire/BackgroundWorker/Env.hs index bc904921a4f..ed784a33db9 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Env.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Env.hs @@ -51,6 +51,7 @@ import Wire.API.Conversation.Protocol (ProtocolTag) import Wire.API.Team.Feature (LegalholdConfig, npProject) import Wire.API.Team.FeatureFlags (FanoutLimit, FeatureFlags) import Wire.BackgroundWorker.Options +import Wire.JobSubsystem.Migrations (mkArbiterConnectionString) import Wire.Options.Galley (GuestLinkTTLSeconds, conversationCodeURISettings) import Wire.Options.Galley qualified as Galley import Wire.Options.Keys (loadAllMLSKeys) @@ -193,7 +194,7 @@ mkEnv opts galleyOpts = do checkGroupInfo = galleyOpts._settings._checkGroupInfo workerRunningGauge <- mkWorkerRunningGauge hasqlPool <- initPostgresPool opts.postgresqlPool galleyOpts._postgresql galleyOpts._postgresqlPassword - arbiterConnStr <- postgresqlConnectionStringWithPassword galleyOpts._postgresql galleyOpts._postgresqlPassword + arbiterConnStr <- mkArbiterConnectionString galleyOpts._postgresql galleyOpts._postgresqlPassword Log.info logger $ Log.msg @Text "Opening RabbitMQ channel: background-worker-jobs-publisher..." amqpJobsPublisherChannel <- mkRabbitMqChannelMVar logger (Just "background-worker-jobs-publisher") $ diff --git a/services/background-worker/src/Wire/BackgroundWorker/Workers.hs b/services/background-worker/src/Wire/BackgroundWorker/Workers.hs index ea2604e1d07..e28c1e92d14 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Workers.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Workers.hs @@ -1,7 +1,6 @@ {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE TypeApplications #-} -- This file is part of the Wire Server implementation. -- @@ -23,20 +22,14 @@ module Wire.BackgroundWorker.Workers (startWorker) where import Arbiter.Core qualified as ArbiterCore -import Arbiter.Core.Job.Types (JobRead, RegistryAdmissionPolicies) -import Arbiter.Core.QueueRegistry (RegistryTables, TableForPayload) import Arbiter.Worker qualified as ArbiterWorker import Arbiter.Worker.Config qualified as ArbiterWorkerConfig import Arbiter.Worker.Cron qualified as ArbiterWorkerCron import Control.Exception (throwIO) import Data.Misc (Duration, duration) -import Data.Proxy (Proxy (..)) import Data.Range (fromRange) -import Data.Secret (SecretText, revealSecretText) import Data.Text qualified as T -import Data.Text.Encoding qualified as Text import Data.Time.Clock (NominalDiffTime) -import GHC.TypeLits (KnownSymbol) import Imports import System.Cron (CronSchedule, serializeCronSchedule) import System.IO.Error (userError) @@ -75,8 +68,6 @@ data JobWorkerSettings = JobWorkerSettings data JobRunnerConfig registry = JobRunnerConfig { jobRunnerLogger :: Log.Logger, jobRunnerSchedule :: CronSchedule, - -- May contain the PostgreSQL password. Keep it wrapped until the Arbiter boundary. - jobRunnerArbiterConnStr :: SecretText, jobRunnerSchemaName :: Text, jobRunnerSettings :: JobWorkerSettings } @@ -109,10 +100,6 @@ startWorker scheduledConfig meetingsCleanupConfig = do JobRunnerConfig { jobRunnerLogger = env.logger, jobRunnerSchedule = meetingsCleanupConfig.schedule, - -- Arbiter still uses the connection string for LISTEN/NOTIFY. - -- The actual job DB access goes through the shared Hasql pool - -- passed from the background-worker environment. - jobRunnerArbiterConnStr = env.arbiterConnStr, jobRunnerSchemaName = ArbiterCore.defaultSchemaName, jobRunnerSettings = workerSettings } :: @@ -137,19 +124,12 @@ toJobJitter = \case -- multi-pool runner, so they share the process lifecycle without sharing a -- payload type or queue. runJobRunner :: - forall registry. - ( RegistryTables registry, - RegistryAdmissionPolicies registry, - KnownSymbol (TableForPayload MeetingsJobPayload registry), - KnownSymbol (TableForPayload ConversationsJobPayload registry) - ) => Env -> ExtEnv -> - JobRunnerConfig registry -> + JobRunnerConfig JobRegistry -> CleanupConfig -> IO (IO ()) runJobRunner env extEnv runnerConfig cleanupConfig = do - let arbiterConnStr = Text.encodeUtf8 (revealSecretText runnerConfig.jobRunnerArbiterConnStr) Log.info runnerConfig.jobRunnerLogger $ Log.msg (Log.val "Starting job worker") . Log.field "queue_names" (T.intercalate "," [meetingsQueueName, conversationsQueueName]) @@ -188,28 +168,24 @@ runJobRunner env extEnv runnerConfig cleanupConfig = do Right job -> pure job meetingsWorkerConfig <- - ( ArbiterWorker.defaultWorkerConfig - arbiterConnStr + ( ArbiterWorker.transactionalWorkerConfig runnerConfig.jobRunnerSettings.jobWorkerThreads meetingsWorkerHandler :: IO ( ArbiterWorker.WorkerConfig - (WireArbiter registry) + (WireArbiter JobRegistry) MeetingsJobPayload - () ) ) conversationsWorkerConfig <- - ( ArbiterWorker.defaultWorkerConfig - arbiterConnStr + ( ArbiterWorker.transactionalWorkerConfig runnerConfig.jobRunnerSettings.jobWorkerThreads conversationsWorkerHandler :: IO ( ArbiterWorker.WorkerConfig - (WireArbiter registry) + (WireArbiter JobRegistry) ConversationsJobPayload - () ) ) @@ -227,21 +203,14 @@ runJobRunner env extEnv runnerConfig cleanupConfig = do [ ArbiterWorker.namedWorkerPool meetingsWorkerConfig', ArbiterWorker.namedWorkerPool conversationsWorkerConfig' ] - shutdownWorkerPools _ = do - ArbiterWorker.shutdownWorker meetingsWorkerConfig' - ArbiterWorker.shutdownWorker conversationsWorkerConfig' workerAsync <- Async.async $ runWireArbiter arbiterEnv $ - ArbiterWorker.runWorkerPools - (Proxy @registry) - workerPools - shutdownWorkerPools + ArbiterWorker.runWorkerPools workerPools pure $ do - ArbiterWorker.shutdownWorker meetingsWorkerConfig' - ArbiterWorker.shutdownWorker conversationsWorkerConfig' + runWireArbiter arbiterEnv $ ArbiterWorker.shutdownPools workerPools Async.cancel workerAsync meetingsJobPayloadTypeName :: MeetingsJobPayload -> Text @@ -254,7 +223,7 @@ conversationsJobPayloadTypeName = \case AdminlessDeletion _ -> "adminless_deletion" AdminlessReminder _ -> "adminless_reminder" -mapJobPayload :: (a -> b) -> JobRead a -> JobRead b +mapJobPayload :: (a -> b) -> ArbiterCore.JobRead a -> ArbiterCore.JobRead b mapJobPayload f job = ArbiterCore.Job { ArbiterCore.primaryKey = job.primaryKey, @@ -274,13 +243,14 @@ mapJobPayload f job = ArbiterCore.parentState = job.parentState, ArbiterCore.suspended = job.suspended, ArbiterCore.claimedBy = job.claimedBy, + ArbiterCore.archiveFor = job.archiveFor, ArbiterCore.admission = job.admission } applyExplicitDefaults :: JobWorkerSettings -> - ArbiterWorker.WorkerConfig m payload result -> - ArbiterWorker.WorkerConfig m payload result + ArbiterWorker.WorkerConfig m payload -> + ArbiterWorker.WorkerConfig m payload applyExplicitDefaults settings cfg = cfg { -- How often the dispatcher wakes up to look for newly visible jobs. diff --git a/services/galley/src/Galley/Run.hs b/services/galley/src/Galley/Run.hs index c01f9490290..7dc8ce40f39 100644 --- a/services/galley/src/Galley/Run.hs +++ b/services/galley/src/Galley/Run.hs @@ -46,7 +46,7 @@ import Galley.Cassandra import Galley.Env import Galley.Monad import Galley.Queue qualified as Q -import Hasql.Pool.Extended (postgresqlConnectionStringWithPassword, rawPool) +import Hasql.Pool.Extended (rawPool) import Imports import Network.HTTP.Media.RenderHeader qualified as HTTPMedia import Network.HTTP.Types qualified as HTTP @@ -68,7 +68,7 @@ import Wire.API.Routes.Public.Galley import Wire.API.Routes.Version import Wire.API.Routes.Version.Wai import Wire.AWS (awsEnv) -import Wire.JobSubsystem.Migrations (runJobMigrations) +import Wire.JobSubsystem.Migrations (mkArbiterConnectionString, runJobMigrations) import Wire.OpenTelemetry (withTracerC) import Wire.Options.Galley import Wire.PostgresMigrations (runAllMigrations) @@ -80,7 +80,7 @@ run opts = lowerCodensity do lift $ runAllMigrations env._hasqlPool.rawPool env._applog arbiterConnStr <- lift $ - postgresqlConnectionStringWithPassword + mkArbiterConnectionString (opts ^. postgresql) (opts ^. postgresqlPassword) lift $ runJobMigrations arbiterConnStr ArbiterCore.defaultSchemaName From f6774bc35502972d46177d5eefb289db81e3dac3 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Tue, 28 Jul 2026 10:02:17 +0200 Subject: [PATCH 037/113] WPB-23631: Move `Spar.Sem.Reporter` to `Wire.Reporter` (#5356) Move the Reporter effect + Wai interpreter from Spar.Sem.Reporter(.Wai) to Wire.Reporter(.Wai) in wire-subsystems. Rewire all consumers (CanonicalInterpreter, Spar.API/App/Scim, Test.Spar.Saml.IdPSpec); delete the old modules; register Wire.Reporter(.Wai) in the cabal. --- changelog.d/2-features/WPB-23631-0 | 1 + .../Sem => libs/wire-subsystems/src/Wire}/Reporter.hs | 4 ++-- .../Sem => libs/wire-subsystems/src/Wire}/Reporter/Wai.hs | 8 ++++---- libs/wire-subsystems/wire-subsystems.cabal | 2 ++ services/spar/spar.cabal | 2 -- services/spar/src/Spar/API.hs | 2 +- services/spar/src/Spar/App.hs | 4 ++-- services/spar/src/Spar/CanonicalInterpreter.hs | 4 ++-- services/spar/src/Spar/Scim.hs | 2 +- services/spar/test/Test/Spar/Saml/IdPSpec.hs | 2 +- 10 files changed, 16 insertions(+), 15 deletions(-) create mode 100644 changelog.d/2-features/WPB-23631-0 rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/Reporter.hs (94%) rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/Reporter/Wai.hs (88%) diff --git a/changelog.d/2-features/WPB-23631-0 b/changelog.d/2-features/WPB-23631-0 new file mode 100644 index 00000000000..feb36540c11 --- /dev/null +++ b/changelog.d/2-features/WPB-23631-0 @@ -0,0 +1 @@ +Move `Spar.Sem.Reporter` to `Wire.Reporter` diff --git a/services/spar/src/Spar/Sem/Reporter.hs b/libs/wire-subsystems/src/Wire/Reporter.hs similarity index 94% rename from services/spar/src/Spar/Sem/Reporter.hs rename to libs/wire-subsystems/src/Wire/Reporter.hs index 77d71f0dce1..c5cc733b832 100644 --- a/services/spar/src/Spar/Sem/Reporter.hs +++ b/libs/wire-subsystems/src/Wire/Reporter.hs @@ -17,14 +17,14 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.Reporter +module Wire.Reporter ( Reporter (..), report, ) where import Imports -import qualified Network.Wai as Wai +import Network.Wai qualified as Wai import Network.Wai.Utilities.Error (Error) import Polysemy diff --git a/services/spar/src/Spar/Sem/Reporter/Wai.hs b/libs/wire-subsystems/src/Wire/Reporter/Wai.hs similarity index 88% rename from services/spar/src/Spar/Sem/Reporter/Wai.hs rename to libs/wire-subsystems/src/Wire/Reporter/Wai.hs index e2d0b66ef66..48e97849ef0 100644 --- a/services/spar/src/Spar/Sem/Reporter/Wai.hs +++ b/libs/wire-subsystems/src/Wire/Reporter/Wai.hs @@ -15,17 +15,17 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.Reporter.Wai +module Wire.Reporter.Wai ( reporterToTinyLogWai, ) where import Imports -import qualified Network.Wai.Utilities.Server as Wai +import Network.Wai.Utilities.Server qualified as Wai import Polysemy import Polysemy.Input -import Spar.Sem.Reporter -import qualified System.Logger as TinyLog +import System.Logger qualified as TinyLog +import Wire.Reporter reporterToTinyLogWai :: ( Member (Embed IO) r, diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index c25b9c9e87f..2d9fb2ff222 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -411,6 +411,8 @@ library Wire.ProposalStore.Cassandra Wire.RateLimit Wire.RateLimit.Interpreter + Wire.Reporter + Wire.Reporter.Wai Wire.Rpc Wire.RpcException Wire.SAMLEmailSubsystem diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal index 561d20dcb01..d0e6523b58c 100644 --- a/services/spar/spar.cabal +++ b/services/spar/spar.cabal @@ -77,8 +77,6 @@ library Spar.Sem.IdPRawMetadataStore.Cassandra Spar.Sem.IdPRawMetadataStore.Mem Spar.Sem.IdPRawMetadataStore.Spec - Spar.Sem.Reporter - Spar.Sem.Reporter.Wai Spar.Sem.SAML2 Spar.Sem.SAML2.Library Spar.Sem.SamlProtocolSettings diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index 9c04f8e7b81..7692924b606 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -102,7 +102,6 @@ import Spar.Sem.DefaultSsoCode (DefaultSsoCode) import qualified Spar.Sem.DefaultSsoCode as DefaultSsoCode import Spar.Sem.IdPRawMetadataStore (IdPRawMetadataStore) import qualified Spar.Sem.IdPRawMetadataStore as IdPRawMetadataStore -import Spar.Sem.Reporter (Reporter) import Spar.Sem.SAML2 (SAML2) import qualified Spar.Sem.SAML2 as SAML2 import Spar.Sem.SAMLUserStore (SAMLUserStore) @@ -136,6 +135,7 @@ import Wire.IdPConfigStore (IdPConfigStore, Replaced (..), Replacing (..)) import qualified Wire.IdPConfigStore as IdPConfigStore import Wire.IdPSubsystem (IdPSubsystem) import qualified Wire.IdPSubsystem as IdPSubsystem +import Wire.Reporter (Reporter) import Wire.ScimSubsystem import Wire.Sem.Logger (Logger) import qualified Wire.Sem.Logger as Logger diff --git a/services/spar/src/Spar/App.hs b/services/spar/src/Spar/App.hs index 8ec7c658523..b80baf1c0ac 100644 --- a/services/spar/src/Spar/App.hs +++ b/services/spar/src/Spar/App.hs @@ -75,8 +75,6 @@ import qualified Spar.Intra.RpcApp as Intra import Spar.Options import Spar.Orphans () import Spar.Sem.AReqIDStore (AReqIDStore) -import Spar.Sem.Reporter (Reporter) -import qualified Spar.Sem.Reporter as Reporter import Spar.Sem.SAMLUserStore (SAMLUserStore) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) @@ -100,6 +98,8 @@ import Wire.GalleyAPIAccess (GalleyAPIAccess) import qualified Wire.GalleyAPIAccess as GalleyAPIAccess import Wire.IdPConfigStore (IdPConfigStore) import qualified Wire.IdPConfigStore as IdPConfigStore +import Wire.Reporter (Reporter) +import qualified Wire.Reporter as Reporter import Wire.ScimSubsystem.Interpreter import Wire.Sem.Logger (Logger) import qualified Wire.Sem.Logger as Logger diff --git a/services/spar/src/Spar/CanonicalInterpreter.hs b/services/spar/src/Spar/CanonicalInterpreter.hs index 235a7f78392..2d485fe849a 100644 --- a/services/spar/src/Spar/CanonicalInterpreter.hs +++ b/services/spar/src/Spar/CanonicalInterpreter.hs @@ -45,8 +45,6 @@ import Spar.Sem.DefaultSsoCode (DefaultSsoCode) import Spar.Sem.DefaultSsoCode.Cassandra (defaultSsoCodeToCassandra) import Spar.Sem.IdPRawMetadataStore (IdPRawMetadataStore) import Spar.Sem.IdPRawMetadataStore.Cassandra (idpRawMetadataStoreToCassandra) -import Spar.Sem.Reporter (Reporter) -import Spar.Sem.Reporter.Wai (reporterToTinyLogWai) import Spar.Sem.SAML2 (SAML2) import Spar.Sem.SAML2.Library (saml2ToSaml2WebSso) import Spar.Sem.SAMLUserStore (SAMLUserStore) @@ -75,6 +73,8 @@ import Wire.IdPConfigStore.Cassandra (idPToCassandra) import Wire.IdPSubsystem (IdPSubsystem) import Wire.IdPSubsystem.Interpreter (IdPSubsystemError, interpretIdPSubsystem) import Wire.ParseException (ParseException, parseExceptionToHttpError) +import Wire.Reporter (Reporter) +import Wire.Reporter.Wai (reporterToTinyLogWai) import Wire.Rpc (Rpc, runRpcWithHttp) import Wire.RpcException import Wire.ScimSubsystem diff --git a/services/spar/src/Spar/Scim.hs b/services/spar/src/Spar/Scim.hs index caf48e2e7bd..0152ca1b02a 100644 --- a/services/spar/src/Spar/Scim.hs +++ b/services/spar/src/Spar/Scim.hs @@ -83,7 +83,6 @@ import Spar.Options import Spar.Scim.Auth import Spar.Scim.Group () import Spar.Scim.User -import Spar.Sem.Reporter (Reporter) import Spar.Sem.SAMLUserStore (SAMLUserStore) import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import Spar.Sem.ScimTokenStore (ScimTokenStore) @@ -101,6 +100,7 @@ import Wire.API.User.Scim import Wire.BrigAPIAccess (BrigAPIAccess) import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.IdPConfigStore (IdPConfigStore) +import Wire.Reporter (Reporter) import Wire.ScimSubsystem import Wire.Sem.Logger (Logger) import Wire.Sem.Now (Now) diff --git a/services/spar/test/Test/Spar/Saml/IdPSpec.hs b/services/spar/test/Test/Spar/Saml/IdPSpec.hs index 04f221d7f37..1164e75dfe7 100644 --- a/services/spar/test/Test/Spar/Saml/IdPSpec.hs +++ b/services/spar/test/Test/Spar/Saml/IdPSpec.hs @@ -36,7 +36,6 @@ import Spar.Sem.AReqIDStore (AReqIDStore (..)) import Spar.Sem.AssIDStore (AssIDStore (..)) import Spar.Sem.IdPRawMetadataStore import Spar.Sem.IdPRawMetadataStore.Mem -import Spar.Sem.Reporter (Reporter (..)) import Spar.Sem.SAML2 (SAML2 (..)) import Spar.Sem.SAMLUserStore import qualified Spar.Sem.SAMLUserStore as SAMLUserStore @@ -69,6 +68,7 @@ import Wire.GalleyAPIAccess (GalleyAPIAccess) import qualified Wire.GalleyAPIAccess import Wire.IdPConfigStore import Wire.IdPConfigStore.Mem +import Wire.Reporter (Reporter (..)) import Wire.Sem.Logger (discardLogs) import Wire.Sem.Logger.TinyLog (LogRecorder (..), newLogRecorder, recordLogs) import Wire.Sem.Random From 83e43b7e83884ec3827fe4471afe3d75777c3b94 Mon Sep 17 00:00:00 2001 From: Valentin Date: Tue, 28 Jul 2026 11:33:29 +0200 Subject: [PATCH 038/113] feat: add multi-ingress-support for envoy gw (#5307) --- .../WPB-25475-multi-ingress-annotations | 4 + charts/wire-ingress/README.md | 34 ++++- charts/wire-ingress/templates/_helpers.tpl | 133 ++++++++++++++++++ .../templates/certificate-federator.yaml | 3 + .../wire-ingress/templates/certificate.yaml | 49 ++++--- charts/wire-ingress/templates/gateway.yaml | 11 +- .../templates/httproute-account-pages.yaml | 37 +++-- .../templates/httproute-nginz-websockets.yaml | 29 ++-- .../templates/httproute-nginz.yaml | 27 ++-- .../wire-ingress/templates/httproute-s3.yaml | 33 +++-- .../templates/httproute-team-settings.yaml | 37 +++-- .../templates/httproute-webapp.yaml | 37 +++-- charts/wire-ingress/values.yaml | 42 ++++++ 13 files changed, 376 insertions(+), 100 deletions(-) create mode 100644 changelog.d/5-internal/WPB-25475-multi-ingress-annotations diff --git a/changelog.d/5-internal/WPB-25475-multi-ingress-annotations b/changelog.d/5-internal/WPB-25475-multi-ingress-annotations new file mode 100644 index 00000000000..8c4467f57ee --- /dev/null +++ b/changelog.d/5-internal/WPB-25475-multi-ingress-annotations @@ -0,0 +1,4 @@ +In multi-domain (multi-ingress) mode, the `wire-ingress` chart now applies the +`httpRoute.annotations` passthrough to every per-domain HTTPRoute, so external-dns +weighted-record annotations (set-identifier/aws-weight) work across all backend +domains. The `service.create` toggle also applies in multi-domain deployments. diff --git a/charts/wire-ingress/README.md b/charts/wire-ingress/README.md index d107cfa3a73..73433734acc 100644 --- a/charts/wire-ingress/README.md +++ b/charts/wire-ingress/README.md @@ -101,10 +101,10 @@ name overrides, etc.) can be found in `values.yaml`. | Old key | Reason | |---|---| | `config.ingressClass` | | -| `ingressName` | Multi-ingress out of scope | -| `config.isAdditionalIngress` | Multi-ingress out of scope | -| `config.renderCSPInIngress` | Multi-ingress out of scope | -| `config.dns.base` | Only used for CSP header rendering, which is a multi-ingress feature | +| `ingressName` | Replaced by `config.domains[].name` — see [Multi-ingress (multiple backend domains)](#multi-ingress-multiple-backend-domains) | +| `config.isAdditionalIngress` | Implicit — every `config.domains` entry after the first is an additional ingress | +| `config.renderCSPInIngress` | CSP is injected automatically on additional domains; opt out per-domain with `config.domains[].renderCSP: false` | +| `config.dns.base` | Replaced by `config.domains[].base` (used for the per-domain CSP wildcard) | | `tls.verify_depth` | Envoy Gateway `ClientTrafficPolicy` does not expose a direct verify-depth knob; the CA chain itself controls this | | `tls.enabled` | Removed — had no effect; all routes are always TLS-terminated | | `secrets.tlsClientCA` | No longer supplied via values. The `federator-ca` ConfigMap is created by the wire-server chart and referenced directly. | @@ -232,9 +232,31 @@ the name is predictable from chart values. --- -### Multi-ingress is out of scope +### Multi-ingress (multiple backend domains) -Single-domain deployments are the only supported topology. Multi-domain support can be added later. +Set `config.domains` **instead of** `config.dns` to serve several domains from one release: + +```yaml +config: + domains: + - name: blueberry + base: blueberry.example.com + dns: { https: nginz-https.blueberry.example.com, ssl: nginz-ssl.blueberry.example.com, webapp: webapp.blueberry.example.com } + - name: red + base: red.example.org + dns: { https: nginz-https.red.example.org, ssl: nginz-ssl.red.example.org, webapp: webapp.red.example.org } + tls: { issuer: { name: letsencrypt-red, kind: ClusterIssuer } } # optional per-domain issuer +``` + +First entry = primary (listener `https`, un-suffixed names, no injected CSP — apps set their own). +Each additional entry gets its own listener `https-`, cert/secret, suffixed routes, and an +injected per-domain CSP header on the webapp/team-settings/account-pages routes (opt out with +`renderCSP: false`). + +Multi-ingress is mutually exclusive with federation: `config.domains` cannot be +combined with `federator.enabled: true`. Use federation with a single backend +domain (`config.dns`), or multi-ingress (`config.domains`) with the federator +disabled — setting both fails template rendering with a clear error. ### HTTP01 certificate challenges diff --git a/charts/wire-ingress/templates/_helpers.tpl b/charts/wire-ingress/templates/_helpers.tpl index 263f190c90c..5586e57d02a 100644 --- a/charts/wire-ingress/templates/_helpers.tpl +++ b/charts/wire-ingress/templates/_helpers.tpl @@ -73,3 +73,136 @@ Name of the Gateway resource. Uses gateway.name if set, otherwise derives one fr {{ include "wire-ingress.fullname" . }}-gateway {{- end -}} {{- end -}} + +{{/* +Normalized list of ingress domains, returned as a JSON array so callers can +`fromJsonArray` and range over it. + +Back-compat: when `config.domains` is NOT set, a single "primary" entry is +derived from the legacy scalar `config.dns` + `gateway.listeners.https.hostname`, +so existing single-domain deployments render exactly as before. + +Multi-domain: `config.domains` is a list; the FIRST entry is the primary +(its resources keep the un-suffixed names, and its frontend apps set their own +CSP so no CSP is injected). Every additional entry gets a `-` suffix, its +own Gateway listener (`https-`), its own certificate/secret, and — being +an "additional ingress" — a per-domain CSP header injected on the app routes. + +Each entry has: suffix, section, hostname, https, ssl, webapp, teamSettings, +accountPages, fakeS3, base, secretName, certName, issuerName, issuerKind, +primary (bool), csp (bool). +*/}} +{{- define "wire-ingress.domains" -}} +{{- $root := . -}} +{{- $fullname := include "wire-ingress.fullname" . -}} +{{- $out := list -}} +{{- if .Values.config.domains -}} + {{/* + Multi-ingress and federation are mutually exclusive. Multi-ingress serves one + backend on several unrelated domains to obfuscate client relationships; the + federator, by contrast, is single-domain and identifies the backend to other + backends. Supporting both at once is out of scope, so fail fast rather than + render a half-working federator on top of a multi-ingress deployment. + */}} + {{- if .Values.federator.enabled -}} + {{- fail "config.domains (multi-ingress) is mutually exclusive with federator.enabled (federation). Choose one: federation with a single backend domain via config.dns, OR multi-ingress via config.domains with federator.enabled=false." -}} + {{- end -}} + {{- range $i, $domain := .Values.config.domains -}} + {{- $primary := eq $i 0 -}} + {{- $name := required "each config.domains entry requires a 'name'" $domain.name -}} + {{- $base := required (printf "config.domains[%d] (%s) requires a 'base' domain" $i $name) $domain.base -}} + {{- $dns := required (printf "config.domains[%d] (%s) requires a 'dns' map" $i $name) $domain.dns -}} + {{- $tls := $domain.tls | default dict -}} + {{- $issuer := $tls.issuer | default dict -}} + {{- $suffix := ternary "" (printf "-%s" $name) $primary -}} + {{- $section := ternary "https" (printf "https-%s" $name) $primary -}} + {{- $secretName := "" -}} + {{- if $tls.secretName -}}{{- $secretName = $tls.secretName -}} + {{- else if $primary -}}{{- $secretName = include "wire-ingress.certificateSecretName" $root -}} + {{- else -}}{{- $secretName = printf "%s-%s-tls-certificate" $fullname $name -}}{{- end -}} + {{- $cspFlag := true -}} + {{- if hasKey $domain "renderCSP" -}}{{- $cspFlag = $domain.renderCSP -}}{{- end -}} + {{/* + Additional domains cannot share the single wildcard secret created by + secret.yaml, and no cert-manager Certificate is rendered when + tls.useCertManager is false. Without a per-domain tls.secretName the Gateway + listener would reference a Secret that nothing ever creates, silently + failing TLS at runtime. Fail fast instead. + */}} + {{- if and (not $primary) (not $root.Values.tls.useCertManager) (not $tls.secretName) -}} + {{- fail (printf "config.domains[%d] (%s): additional domains need their own TLS secret, but tls.useCertManager is false and no config.domains[%d].tls.secretName is set. Either enable cert-manager (tls.useCertManager: true) or point tls.secretName at a pre-created kubernetes.io/tls Secret for this domain." $i $name $i) -}} + {{- end -}} + {{- $entry := dict + "suffix" $suffix + "section" $section + "hostname" ($domain.hostname | default (printf "*.%s" $base)) + "https" (required (printf "config.domains[%d] (%s) requires dns.https" $i $name) $dns.https) + "ssl" ($dns.ssl | default "") + "webapp" ($dns.webapp | default "") + "teamSettings" ($dns.teamSettings | default "") + "accountPages" ($dns.accountPages | default "") + "fakeS3" ($dns.fakeS3 | default "") + "base" $base + "secretName" $secretName + "certName" (printf "%s-csr" ($base | replace "." "-")) + "issuerName" ($issuer.name | default $root.Values.tls.issuer.name) + "issuerKind" ($issuer.kind | default $root.Values.tls.issuer.kind) + "primary" $primary + "csp" (and (not $primary) $cspFlag) -}} + {{- $out = append $out $entry -}} + {{- end -}} +{{- else -}} + {{- $dns := .Values.config.dns -}} + {{- $base := include "wire-ingress.zone" . -}} + {{- $entry := dict + "suffix" "" + "section" "https" + "hostname" .Values.gateway.listeners.https.hostname + "https" (required "config.dns.https is required" $dns.https) + "ssl" ($dns.ssl | default "") + "webapp" ($dns.webapp | default "") + "teamSettings" ($dns.teamSettings | default "") + "accountPages" ($dns.accountPages | default "") + "fakeS3" ($dns.fakeS3 | default "") + "base" $base + "secretName" (include "wire-ingress.certificateSecretName" .) + "certName" (printf "%s-csr" ($base | replace "." "-")) + "issuerName" .Values.tls.issuer.name + "issuerKind" .Values.tls.issuer.kind + "primary" true + "csp" false -}} + {{- $out = append $out $entry -}} +{{- end -}} +{{- $out | toJson -}} +{{- end -}} + +{{/* +Content-Security-Policy header value for an "additional ingress" domain. +This mirrors the approximation the legacy nginx-ingress-services chart injected +for multi-ingress domains (charts/nginx-ingress-services/templates/ingress.yaml), +where the primary domain's frontend apps set CSP themselves but additional +domains need the header set at the front door. + +Call with a dict: {https, ssl, base, websockets (bool)}. +*/}} +{{- define "wire-ingress.cspHeader" -}} +{{- $csp := printf "connect-src 'self' blob: data: https://*.giphy.com https://%s" .https -}} +{{- if and .websockets .ssl -}}{{- $csp = printf "%s wss://%s" $csp .ssl -}}{{- end -}} +{{- $csp = printf "%s https://*.%s;" $csp .base -}} +{{- $csp = printf "%s default-src 'self';" $csp -}} +{{- $csp = printf "%s font-src 'self' data:;" $csp -}} +{{- $csp = printf "%s frame-src https://*.soundcloud.com https://*.spotify.com https://*.vimeo.com https://*.youtube-nocookie.com;" $csp -}} +{{- $csp = printf "%s img-src 'self' blob: data: https://*.giphy.com https://*.%s;" $csp .base -}} +{{- $csp = printf "%s manifest-src 'self';" $csp -}} +{{- $csp = printf "%s media-src 'self' blob: data:;" $csp -}} +{{- $csp = printf "%s object-src 'none';" $csp -}} +{{- $csp = printf "%s script-src 'self' 'unsafe-eval' https://*.%s;" $csp .base -}} +{{- $csp = printf "%s style-src 'self' 'unsafe-inline';" $csp -}} +{{- $csp = printf "%s worker-src 'self' blob:;" $csp -}} +{{- $csp = printf "%s base-uri 'self';" $csp -}} +{{- $csp = printf "%s form-action 'self';" $csp -}} +{{- $csp = printf "%s frame-ancestors 'self';" $csp -}} +{{- $csp = printf "%s script-src-attr 'none';" $csp -}} +{{- $csp = printf "%s upgrade-insecure-requests" $csp -}} +{{- $csp -}} +{{- end -}} diff --git a/charts/wire-ingress/templates/certificate-federator.yaml b/charts/wire-ingress/templates/certificate-federator.yaml index 0e5ef5219cc..1acebfe1947 100644 --- a/charts/wire-ingress/templates/certificate-federator.yaml +++ b/charts/wire-ingress/templates/certificate-federator.yaml @@ -1,3 +1,6 @@ +{{- if and .Values.federator.enabled .Values.config.domains -}} +{{- fail "config.domains (multi-ingress) is mutually exclusive with federator.enabled (federation). Choose one: federation with a single backend domain via config.dns, OR multi-ingress via config.domains with federator.enabled=false." -}} +{{- end -}} {{- if and .Values.federator.enabled .Values.federator.tls.useCertManager }} apiVersion: cert-manager.io/v1 kind: Certificate diff --git a/charts/wire-ingress/templates/certificate.yaml b/charts/wire-ingress/templates/certificate.yaml index 61ee9cb272b..2bb262c7d2f 100644 --- a/charts/wire-ingress/templates/certificate.yaml +++ b/charts/wire-ingress/templates/certificate.yaml @@ -1,45 +1,50 @@ {{- if .Values.tls.useCertManager -}} +{{- $root := . -}} +{{- $domains := include "wire-ingress.domains" . | fromJsonArray -}} +{{- range $domain := $domains }} +--- apiVersion: cert-manager.io/v1 kind: Certificate metadata: - name: "{{ include "wire-ingress.zone" . | replace "." "-" }}-csr" - namespace: {{ .Release.Namespace }} + name: "{{ $domain.certName }}" + namespace: {{ $root.Release.Namespace }} labels: - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" + chart: "{{ $root.Chart.Name }}-{{ $root.Chart.Version }}" + release: "{{ $root.Release.Name }}" + heritage: "{{ $root.Release.Service }}" spec: issuerRef: - name: {{ include "wire-ingress.issuerName" . | quote }} - kind: {{ .Values.tls.issuer.kind }} + name: {{ $domain.issuerName | quote }} + kind: {{ $domain.issuerKind }} usages: - server auth duration: 2160h # 90d, Letsencrypt default; NOTE: changes are ignored by Letsencrypt renewBefore: 360h # 15d isCA: false - secretName: {{ include "wire-ingress.certificateSecretName" . | quote }} + secretName: {{ $domain.secretName | quote }} privateKey: - algorithm: {{ .Values.tls.privateKey.algorithm }} - size: {{ .Values.tls.privateKey.size }} + algorithm: {{ $root.Values.tls.privateKey.algorithm }} + size: {{ $root.Values.tls.privateKey.size }} encoding: PKCS1 - rotationPolicy: {{ .Values.tls.privateKey.rotationPolicy }} + rotationPolicy: {{ $root.Values.tls.privateKey.rotationPolicy }} dnsNames: - - {{ .Values.config.dns.https }} - {{- if .Values.websockets.enabled }} - - {{ .Values.config.dns.ssl }} + - {{ $domain.https }} + {{- if and $root.Values.websockets.enabled $domain.ssl }} + - {{ $domain.ssl }} {{- end }} - {{- if .Values.webapp.enabled }} - - {{ .Values.config.dns.webapp }} + {{- if and $root.Values.webapp.enabled $domain.webapp }} + - {{ $domain.webapp }} {{- end }} - {{- if .Values.fakeS3.enabled }} - - {{ .Values.config.dns.fakeS3 }} + {{- if and $root.Values.fakeS3.enabled $domain.fakeS3 }} + - {{ $domain.fakeS3 }} {{- end }} - {{- if .Values.teamSettings.enabled }} - - {{ .Values.config.dns.teamSettings }} + {{- if and $root.Values.teamSettings.enabled $domain.teamSettings }} + - {{ $domain.teamSettings }} {{- end }} - {{- if .Values.accountPages.enabled }} - - {{ .Values.config.dns.accountPages }} + {{- if and $root.Values.accountPages.enabled $domain.accountPages }} + - {{ $domain.accountPages }} {{- end }} +{{- end }} {{- end -}} diff --git a/charts/wire-ingress/templates/gateway.yaml b/charts/wire-ingress/templates/gateway.yaml index 16b3f7bb720..b547aae3e60 100644 --- a/charts/wire-ingress/templates/gateway.yaml +++ b/charts/wire-ingress/templates/gateway.yaml @@ -33,15 +33,18 @@ spec: {{- end }} {{- end }} listeners: - - name: https - port: {{ .Values.gateway.listeners.https.port }} + {{- $domains := include "wire-ingress.domains" . | fromJsonArray }} + {{- range $domain := $domains }} + - name: {{ $domain.section }} + port: {{ $.Values.gateway.listeners.https.port }} protocol: HTTPS - hostname: {{ required "gateway.listeners.https.hostname is required (see values.yaml for details)" .Values.gateway.listeners.https.hostname | quote }} + hostname: {{ required "an HTTPS listener hostname is required (gateway.listeners.https.hostname for single-domain, or config.domains[].base/hostname)" $domain.hostname | quote }} tls: mode: Terminate certificateRefs: - - name: {{ include "wire-ingress.certificateSecretName" . | quote }} + - name: {{ $domain.secretName | quote }} kind: Secret + {{- end }} {{- if .Values.federator.enabled }} - name: federator port: {{ .Values.gateway.listeners.https.port }} diff --git a/charts/wire-ingress/templates/httproute-account-pages.yaml b/charts/wire-ingress/templates/httproute-account-pages.yaml index 8f4d12aa456..385876a2e31 100644 --- a/charts/wire-ingress/templates/httproute-account-pages.yaml +++ b/charts/wire-ingress/templates/httproute-account-pages.yaml @@ -1,32 +1,47 @@ {{- if .Values.accountPages.enabled }} +{{- $root := . -}} +{{- $domains := include "wire-ingress.domains" . | fromJsonArray -}} +{{- range $domain := $domains }} +{{- if $domain.accountPages }} +--- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: {{ include "wire-ingress.fullname" . }}-account-pages - namespace: {{ .Release.Namespace }} - {{- with .Values.httpRoute.annotations }} + name: {{ include "wire-ingress.fullname" $root }}-account-pages{{ $domain.suffix }} + namespace: {{ $root.Release.Namespace }} + {{- with $root.Values.httpRoute.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} labels: - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" + chart: "{{ $root.Chart.Name }}-{{ $root.Chart.Version }}" + release: "{{ $root.Release.Name }}" + heritage: "{{ $root.Release.Service }}" spec: parentRefs: - - name: {{ include "wire-ingress.gatewayName" . | quote }} - namespace: {{ .Release.Namespace | quote }} + - name: {{ include "wire-ingress.gatewayName" $root | quote }} + namespace: {{ $root.Release.Namespace | quote }} kind: Gateway - sectionName: https + sectionName: {{ $domain.section }} hostnames: - - {{ required "config.dns.accountPages is required when accountPages.enabled is true" .Values.config.dns.accountPages | quote }} + - {{ required "config.dns.accountPages is required when accountPages.enabled is true" $domain.accountPages | quote }} rules: - matches: - path: type: PathPrefix value: / + {{- if $domain.csp }} + filters: + - type: ResponseHeaderModifier + responseHeaderModifier: + set: + - name: Content-Security-Policy + value: {{ include "wire-ingress.cspHeader" (dict "https" $domain.https "ssl" $domain.ssl "base" $domain.base "websockets" $root.Values.websockets.enabled) | quote }} + {{- end }} backendRefs: - name: account-pages-http - port: {{ .Values.service.accountPages.externalPort }} + port: {{ $root.Values.service.accountPages.externalPort }} kind: Service {{- end }} +{{- end }} +{{- end }} diff --git a/charts/wire-ingress/templates/httproute-nginz-websockets.yaml b/charts/wire-ingress/templates/httproute-nginz-websockets.yaml index a0c34d80b95..6a49f499e32 100644 --- a/charts/wire-ingress/templates/httproute-nginz-websockets.yaml +++ b/charts/wire-ingress/templates/httproute-nginz-websockets.yaml @@ -1,25 +1,30 @@ {{- if .Values.websockets.enabled }} +{{- $root := . -}} +{{- $domains := include "wire-ingress.domains" . | fromJsonArray -}} +{{- range $domain := $domains }} +{{- if $domain.ssl }} +--- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: {{ include "wire-ingress.fullname" . }}-nginz-websockets - namespace: {{ .Release.Namespace }} - {{- with .Values.httpRoute.annotations }} + name: {{ include "wire-ingress.fullname" $root }}-nginz-websockets{{ $domain.suffix }} + namespace: {{ $root.Release.Namespace }} + {{- with $root.Values.httpRoute.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} labels: - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" + chart: "{{ $root.Chart.Name }}-{{ $root.Chart.Version }}" + release: "{{ $root.Release.Name }}" + heritage: "{{ $root.Release.Service }}" spec: parentRefs: - - name: {{ include "wire-ingress.gatewayName" . | quote }} - namespace: {{ .Release.Namespace | quote }} + - name: {{ include "wire-ingress.gatewayName" $root | quote }} + namespace: {{ $root.Release.Namespace | quote }} kind: Gateway - sectionName: https + sectionName: {{ $domain.section }} hostnames: - - {{ required "config.dns.ssl is required when websockets.enabled is true" .Values.config.dns.ssl | quote }} + - {{ required "config.dns.ssl is required when websockets.enabled is true" $domain.ssl | quote }} rules: - matches: - path: @@ -27,6 +32,8 @@ spec: value: / backendRefs: - name: nginz - port: {{ .Values.service.nginz.wsPort }} + port: {{ $root.Values.service.nginz.wsPort }} kind: Service {{- end }} +{{- end }} +{{- end }} diff --git a/charts/wire-ingress/templates/httproute-nginz.yaml b/charts/wire-ingress/templates/httproute-nginz.yaml index 907fa17722c..6d020f232a6 100644 --- a/charts/wire-ingress/templates/httproute-nginz.yaml +++ b/charts/wire-ingress/templates/httproute-nginz.yaml @@ -1,24 +1,28 @@ +{{- $root := . -}} +{{- $domains := include "wire-ingress.domains" . | fromJsonArray -}} +{{- range $domain := $domains }} +--- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: {{ include "wire-ingress.fullname" . }}-nginz - namespace: {{ .Release.Namespace }} - {{- with .Values.httpRoute.annotations }} + name: {{ include "wire-ingress.fullname" $root }}-nginz{{ $domain.suffix }} + namespace: {{ $root.Release.Namespace }} + {{- with $root.Values.httpRoute.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} labels: - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" + chart: "{{ $root.Chart.Name }}-{{ $root.Chart.Version }}" + release: "{{ $root.Release.Name }}" + heritage: "{{ $root.Release.Service }}" spec: parentRefs: - - name: {{ include "wire-ingress.gatewayName" . | quote }} - namespace: {{ .Release.Namespace | quote }} + - name: {{ include "wire-ingress.gatewayName" $root | quote }} + namespace: {{ $root.Release.Namespace | quote }} kind: Gateway - sectionName: https + sectionName: {{ $domain.section }} hostnames: - - {{ required "config.dns.https is required" .Values.config.dns.https | quote }} + - {{ required "config.dns.https is required" $domain.https | quote }} rules: - matches: - path: @@ -26,5 +30,6 @@ spec: value: / backendRefs: - name: nginz - port: {{ .Values.service.nginz.httpPort }} + port: {{ $root.Values.service.nginz.httpPort }} kind: Service +{{- end }} diff --git a/charts/wire-ingress/templates/httproute-s3.yaml b/charts/wire-ingress/templates/httproute-s3.yaml index 7568e1700f3..a5e0cd2a3b5 100644 --- a/charts/wire-ingress/templates/httproute-s3.yaml +++ b/charts/wire-ingress/templates/httproute-s3.yaml @@ -1,34 +1,41 @@ {{- if .Values.fakeS3.enabled }} +{{- $root := . -}} +{{- $domains := include "wire-ingress.domains" . | fromJsonArray -}} +{{- range $domain := $domains }} +{{- if $domain.fakeS3 }} +--- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: {{ include "wire-ingress.fullname" . }}-minio - namespace: {{ .Release.Namespace }} - {{- with .Values.httpRoute.annotations }} + name: {{ include "wire-ingress.fullname" $root }}-minio{{ $domain.suffix }} + namespace: {{ $root.Release.Namespace }} + {{- with $root.Values.httpRoute.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} labels: - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" + chart: "{{ $root.Chart.Name }}-{{ $root.Chart.Version }}" + release: "{{ $root.Release.Name }}" + heritage: "{{ $root.Release.Service }}" spec: parentRefs: - - name: {{ include "wire-ingress.gatewayName" . | quote }} - namespace: {{ .Release.Namespace | quote }} + - name: {{ include "wire-ingress.gatewayName" $root | quote }} + namespace: {{ $root.Release.Namespace | quote }} kind: Gateway - sectionName: https + sectionName: {{ $domain.section }} hostnames: - - {{ required "config.dns.fakeS3 is required when fakeS3.enabled is true" .Values.config.dns.fakeS3 | quote }} + - {{ required "config.dns.fakeS3 is required when fakeS3.enabled is true" $domain.fakeS3 | quote }} rules: - {{- toYaml .Values.fakeS3.guardingRules | nindent 4 }} + {{- toYaml $root.Values.fakeS3.guardingRules | nindent 4 }} {{/* Default catch-all rule routes to the S3 backend */}} - matches: - path: type: PathPrefix value: / backendRefs: - - name: {{ .Values.service.s3.serviceName }} - port: {{ .Values.service.s3.externalPort }} + - name: {{ $root.Values.service.s3.serviceName }} + port: {{ $root.Values.service.s3.externalPort }} kind: Service {{- end }} +{{- end }} +{{- end }} diff --git a/charts/wire-ingress/templates/httproute-team-settings.yaml b/charts/wire-ingress/templates/httproute-team-settings.yaml index e523788439f..54f26f28447 100644 --- a/charts/wire-ingress/templates/httproute-team-settings.yaml +++ b/charts/wire-ingress/templates/httproute-team-settings.yaml @@ -1,32 +1,47 @@ {{- if .Values.teamSettings.enabled }} +{{- $root := . -}} +{{- $domains := include "wire-ingress.domains" . | fromJsonArray -}} +{{- range $domain := $domains }} +{{- if $domain.teamSettings }} +--- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: {{ include "wire-ingress.fullname" . }}-team-settings - namespace: {{ .Release.Namespace }} - {{- with .Values.httpRoute.annotations }} + name: {{ include "wire-ingress.fullname" $root }}-team-settings{{ $domain.suffix }} + namespace: {{ $root.Release.Namespace }} + {{- with $root.Values.httpRoute.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} labels: - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" + chart: "{{ $root.Chart.Name }}-{{ $root.Chart.Version }}" + release: "{{ $root.Release.Name }}" + heritage: "{{ $root.Release.Service }}" spec: parentRefs: - - name: {{ include "wire-ingress.gatewayName" . | quote }} - namespace: {{ .Release.Namespace | quote }} + - name: {{ include "wire-ingress.gatewayName" $root | quote }} + namespace: {{ $root.Release.Namespace | quote }} kind: Gateway - sectionName: https + sectionName: {{ $domain.section }} hostnames: - - {{ required "config.dns.teamSettings is required when teamSettings.enabled is true" .Values.config.dns.teamSettings | quote }} + - {{ required "config.dns.teamSettings is required when teamSettings.enabled is true" $domain.teamSettings | quote }} rules: - matches: - path: type: PathPrefix value: / + {{- if $domain.csp }} + filters: + - type: ResponseHeaderModifier + responseHeaderModifier: + set: + - name: Content-Security-Policy + value: {{ include "wire-ingress.cspHeader" (dict "https" $domain.https "ssl" $domain.ssl "base" $domain.base "websockets" $root.Values.websockets.enabled) | quote }} + {{- end }} backendRefs: - name: team-settings-http - port: {{ .Values.service.teamSettings.externalPort }} + port: {{ $root.Values.service.teamSettings.externalPort }} kind: Service {{- end }} +{{- end }} +{{- end }} diff --git a/charts/wire-ingress/templates/httproute-webapp.yaml b/charts/wire-ingress/templates/httproute-webapp.yaml index cc17a2d9404..07844ec80a9 100644 --- a/charts/wire-ingress/templates/httproute-webapp.yaml +++ b/charts/wire-ingress/templates/httproute-webapp.yaml @@ -1,32 +1,47 @@ {{- if .Values.webapp.enabled }} +{{- $root := . -}} +{{- $domains := include "wire-ingress.domains" . | fromJsonArray -}} +{{- range $domain := $domains }} +{{- if $domain.webapp }} +--- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: {{ include "wire-ingress.fullname" . }}-webapp - namespace: {{ .Release.Namespace }} - {{- with .Values.httpRoute.annotations }} + name: {{ include "wire-ingress.fullname" $root }}-webapp{{ $domain.suffix }} + namespace: {{ $root.Release.Namespace }} + {{- with $root.Values.httpRoute.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} labels: - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" + chart: "{{ $root.Chart.Name }}-{{ $root.Chart.Version }}" + release: "{{ $root.Release.Name }}" + heritage: "{{ $root.Release.Service }}" spec: parentRefs: - - name: {{ include "wire-ingress.gatewayName" . | quote }} - namespace: {{ .Release.Namespace | quote }} + - name: {{ include "wire-ingress.gatewayName" $root | quote }} + namespace: {{ $root.Release.Namespace | quote }} kind: Gateway - sectionName: https + sectionName: {{ $domain.section }} hostnames: - - {{ required "config.dns.webapp is required when webapp.enabled is true" .Values.config.dns.webapp | quote }} + - {{ required "config.dns.webapp is required when webapp.enabled is true" $domain.webapp | quote }} rules: - matches: - path: type: PathPrefix value: / + {{- if $domain.csp }} + filters: + - type: ResponseHeaderModifier + responseHeaderModifier: + set: + - name: Content-Security-Policy + value: {{ include "wire-ingress.cspHeader" (dict "https" $domain.https "ssl" $domain.ssl "base" $domain.base "websockets" $root.Values.websockets.enabled) | quote }} + {{- end }} backendRefs: - name: webapp-http - port: {{ .Values.service.webapp.externalPort }} + port: {{ $root.Values.service.webapp.externalPort }} kind: Service {{- end }} +{{- end }} +{{- end }} diff --git a/charts/wire-ingress/values.yaml b/charts/wire-ingress/values.yaml index 756938a7130..bcd14ac911b 100644 --- a/charts/wire-ingress/values.yaml +++ b/charts/wire-ingress/values.yaml @@ -117,6 +117,48 @@ gateway: # certificateDomain: federator. # domain to use in the federator CSR # teamSettings: teams. # ignored unless teamSettings.enabled == true # accountPages: account. # ignored unless accountPages.enabled == true +# +# MULTI-INGRESS (multiple backend domains from one release) +# --------------------------------------------------------- +# To serve the same backend on several domains, set config.domains INSTEAD of +# config.dns. It is a list; the FIRST entry is the primary (its resources keep +# the un-suffixed names, and its frontend apps set their own CSP). Every +# additional entry gets its own Gateway HTTPS listener (`https-`), its own +# certificate/secret, and — being an "additional ingress" — a per-domain +# Content-Security-Policy header injected on the webapp/team-settings/ +# account-pages routes (mirrors the legacy nginx-ingress-services behaviour). +# +# config.dns and config.domains are mutually exclusive; config.domains wins. +# +# Multi-ingress is ALSO mutually exclusive with federation: you cannot enable +# federator.enabled together with config.domains. Multi-ingress is the special +# case (a single customer serving one backend on several unrelated domains); +# federation is the common case. Pick one — federation with a single backend +# domain via config.dns, or multi-ingress via config.domains with the federator +# disabled. Setting both fails template rendering with a clear error. +# +# config: +# domains: +# - name: blueberry # required; used for resource-name suffix & listener section +# base: blueberry.example.com # required; CSP wildcard (*.base) and default listener hostname (*.base) +# # hostname: "*.blueberry.example.com" # optional listener hostname override (defaults to *.base) +# dns: +# https: nginz-https.blueberry.example.com +# ssl: nginz-ssl.blueberry.example.com +# webapp: webapp.blueberry.example.com +# # teamSettings / accountPages / fakeS3 as needed +# - name: red +# base: red.example.org +# dns: +# https: nginz-https.red.example.org +# ssl: nginz-ssl.red.example.org +# webapp: webapp.red.example.org +# # renderCSP: false # optional: disable the injected CSP for this domain +# tls: +# # secretName: "" # optional TLS secret name override (defaults to a per-domain name) +# issuer: # optional per-domain cert-manager issuer override (defaults to tls.issuer) +# name: letsencrypt-red +# kind: ClusterIssuer websockets: enabled: true From 588240a6363f0d2188617f2f355811df8c22a579 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Tue, 28 Jul 2026 17:11:56 +0200 Subject: [PATCH 039/113] WPB-23631: Move `Spar.Sem.SamlProtocolSettings` to `Wire.SamlProtocolSettings` (#5377) Move the SamlProtocolSettings effect + Servant interpreter to Wire.SamlProtocolSettings(.Servant). Interpreter keeps taking SAML.Config as an arg (unchanged). Rewire consumers (CanonicalInterpreter, Spar.API, Test.Spar.Saml.IdPSpec, Test.Spar.Sem.SamlProtocolSettingsSpec); delete old modules; register in cabal. --- changelog.d/{2-features => 5-internal}/WPB-23631-0 | 0 changelog.d/5-internal/WPB-23631-1 | 1 + .../wire-subsystems/src/Wire}/SamlProtocolSettings.hs | 6 +++--- .../src/Wire}/SamlProtocolSettings/Servant.hs | 6 +++--- libs/wire-subsystems/wire-subsystems.cabal | 2 ++ services/spar/spar.cabal | 2 -- services/spar/src/Spar/API.hs | 4 ++-- services/spar/src/Spar/CanonicalInterpreter.hs | 4 ++-- services/spar/test/Test/Spar/Saml/IdPSpec.hs | 4 ++-- .../spar/test/Test/Spar/Sem/SamlProtocolSettingsSpec.hs | 4 ++-- 10 files changed, 17 insertions(+), 16 deletions(-) rename changelog.d/{2-features => 5-internal}/WPB-23631-0 (100%) create mode 100644 changelog.d/5-internal/WPB-23631-1 rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/SamlProtocolSettings.hs (91%) rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/SamlProtocolSettings/Servant.hs (91%) diff --git a/changelog.d/2-features/WPB-23631-0 b/changelog.d/5-internal/WPB-23631-0 similarity index 100% rename from changelog.d/2-features/WPB-23631-0 rename to changelog.d/5-internal/WPB-23631-0 diff --git a/changelog.d/5-internal/WPB-23631-1 b/changelog.d/5-internal/WPB-23631-1 new file mode 100644 index 00000000000..8782ca6afa7 --- /dev/null +++ b/changelog.d/5-internal/WPB-23631-1 @@ -0,0 +1 @@ +Move `Spar.Sem.SamlProtocolSettings` to `Wire.SamlProtocolSettings` diff --git a/services/spar/src/Spar/Sem/SamlProtocolSettings.hs b/libs/wire-subsystems/src/Wire/SamlProtocolSettings.hs similarity index 91% rename from services/spar/src/Spar/Sem/SamlProtocolSettings.hs rename to libs/wire-subsystems/src/Wire/SamlProtocolSettings.hs index 8b0bb3f34c6..20397dc20cc 100644 --- a/services/spar/src/Spar/Sem/SamlProtocolSettings.hs +++ b/libs/wire-subsystems/src/Wire/SamlProtocolSettings.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.SamlProtocolSettings +module Wire.SamlProtocolSettings ( SamlProtocolSettings (..), spIssuer, responseURI, @@ -29,8 +29,8 @@ import Data.Domain import Data.Id (TeamId) import Imports import Polysemy -import qualified SAML2.WebSSO.Types as SAML -import qualified URI.ByteString as URI +import SAML2.WebSSO.Types qualified as SAML +import URI.ByteString qualified as URI data SamlProtocolSettings m a where SpIssuer :: Maybe TeamId -> Maybe Domain -> SamlProtocolSettings m (Maybe SAML.Issuer) diff --git a/services/spar/src/Spar/Sem/SamlProtocolSettings/Servant.hs b/libs/wire-subsystems/src/Wire/SamlProtocolSettings/Servant.hs similarity index 91% rename from services/spar/src/Spar/Sem/SamlProtocolSettings/Servant.hs rename to libs/wire-subsystems/src/Wire/SamlProtocolSettings/Servant.hs index 787700a46a8..fbd2c864016 100644 --- a/services/spar/src/Spar/Sem/SamlProtocolSettings/Servant.hs +++ b/libs/wire-subsystems/src/Wire/SamlProtocolSettings/Servant.hs @@ -18,16 +18,16 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.SamlProtocolSettings.Servant +module Wire.SamlProtocolSettings.Servant ( sparRouteToServant, ) where import Imports import Polysemy -import qualified SAML2.WebSSO as SAML -import Spar.Sem.SamlProtocolSettings +import SAML2.WebSSO qualified as SAML import Wire.API.Routes.Public.Spar +import Wire.SamlProtocolSettings sparRouteToServant :: SAML.Config -> Sem (SamlProtocolSettings ': r) a -> Sem r a sparRouteToServant cfg = interpret $ \case diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 2d9fb2ff222..4b4a0927d43 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -417,6 +417,8 @@ library Wire.RpcException Wire.SAMLEmailSubsystem Wire.SAMLEmailSubsystem.Interpreter + Wire.SamlProtocolSettings + Wire.SamlProtocolSettings.Servant Wire.ScimSubsystem Wire.ScimSubsystem.Error Wire.ScimSubsystem.Interpreter diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal index d0e6523b58c..6da4c522697 100644 --- a/services/spar/spar.cabal +++ b/services/spar/spar.cabal @@ -79,8 +79,6 @@ library Spar.Sem.IdPRawMetadataStore.Spec Spar.Sem.SAML2 Spar.Sem.SAML2.Library - Spar.Sem.SamlProtocolSettings - Spar.Sem.SamlProtocolSettings.Servant Spar.Sem.SAMLUserStore Spar.Sem.SAMLUserStore.Cassandra Spar.Sem.SAMLUserStore.Mem diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index 7692924b606..632c08dd0af 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -106,8 +106,6 @@ import Spar.Sem.SAML2 (SAML2) import qualified Spar.Sem.SAML2 as SAML2 import Spar.Sem.SAMLUserStore (SAMLUserStore) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore -import Spar.Sem.SamlProtocolSettings (SamlProtocolSettings) -import qualified Spar.Sem.SamlProtocolSettings as SamlProtocolSettings import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import Spar.Sem.ScimTokenStore (ScimTokenStore) import qualified Spar.Sem.ScimTokenStore as ScimTokenStore @@ -136,6 +134,8 @@ import qualified Wire.IdPConfigStore as IdPConfigStore import Wire.IdPSubsystem (IdPSubsystem) import qualified Wire.IdPSubsystem as IdPSubsystem import Wire.Reporter (Reporter) +import Wire.SamlProtocolSettings (SamlProtocolSettings) +import qualified Wire.SamlProtocolSettings as SamlProtocolSettings import Wire.ScimSubsystem import Wire.Sem.Logger (Logger) import qualified Wire.Sem.Logger as Logger diff --git a/services/spar/src/Spar/CanonicalInterpreter.hs b/services/spar/src/Spar/CanonicalInterpreter.hs index 2d485fe849a..684bcfbac89 100644 --- a/services/spar/src/Spar/CanonicalInterpreter.hs +++ b/services/spar/src/Spar/CanonicalInterpreter.hs @@ -49,8 +49,6 @@ import Spar.Sem.SAML2 (SAML2) import Spar.Sem.SAML2.Library (saml2ToSaml2WebSso) import Spar.Sem.SAMLUserStore (SAMLUserStore) import Spar.Sem.SAMLUserStore.Cassandra (samlUserStoreToCassandra) -import Spar.Sem.SamlProtocolSettings (SamlProtocolSettings) -import Spar.Sem.SamlProtocolSettings.Servant (sparRouteToServant) import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import Spar.Sem.ScimExternalIdStore.Cassandra (scimExternalIdStoreToCassandra) import Spar.Sem.ScimTokenStore (ScimTokenStore) @@ -77,6 +75,8 @@ import Wire.Reporter (Reporter) import Wire.Reporter.Wai (reporterToTinyLogWai) import Wire.Rpc (Rpc, runRpcWithHttp) import Wire.RpcException +import Wire.SamlProtocolSettings (SamlProtocolSettings) +import Wire.SamlProtocolSettings.Servant (sparRouteToServant) import Wire.ScimSubsystem import Wire.ScimSubsystem.Interpreter import Wire.Sem.Logger.TinyLog (loggerToTinyLog, stringLoggerToTinyLog) diff --git a/services/spar/test/Test/Spar/Saml/IdPSpec.hs b/services/spar/test/Test/Spar/Saml/IdPSpec.hs index 1164e75dfe7..4816fb3cb35 100644 --- a/services/spar/test/Test/Spar/Saml/IdPSpec.hs +++ b/services/spar/test/Test/Spar/Saml/IdPSpec.hs @@ -40,8 +40,6 @@ import Spar.Sem.SAML2 (SAML2 (..)) import Spar.Sem.SAMLUserStore import qualified Spar.Sem.SAMLUserStore as SAMLUserStore import Spar.Sem.SAMLUserStore.Mem -import Spar.Sem.SamlProtocolSettings (SamlProtocolSettings) -import Spar.Sem.SamlProtocolSettings.Servant (sparRouteToServant) import Spar.Sem.ScimTokenStore import Spar.Sem.ScimTokenStore.Mem import qualified Spar.Sem.VerdictFormatStore as VerdictFormatStore @@ -69,6 +67,8 @@ import qualified Wire.GalleyAPIAccess import Wire.IdPConfigStore import Wire.IdPConfigStore.Mem import Wire.Reporter (Reporter (..)) +import Wire.SamlProtocolSettings (SamlProtocolSettings) +import Wire.SamlProtocolSettings.Servant (sparRouteToServant) import Wire.Sem.Logger (discardLogs) import Wire.Sem.Logger.TinyLog (LogRecorder (..), newLogRecorder, recordLogs) import Wire.Sem.Random diff --git a/services/spar/test/Test/Spar/Sem/SamlProtocolSettingsSpec.hs b/services/spar/test/Test/Spar/Sem/SamlProtocolSettingsSpec.hs index af78e5b7245..ed48fed68ce 100644 --- a/services/spar/test/Test/Spar/Sem/SamlProtocolSettingsSpec.hs +++ b/services/spar/test/Test/Spar/Sem/SamlProtocolSettingsSpec.hs @@ -27,11 +27,11 @@ import qualified Data.Text.Encoding as T import Imports import Polysemy import SAML2.WebSSO -import Spar.Sem.SamlProtocolSettings -import Spar.Sem.SamlProtocolSettings.Servant (sparRouteToServant) import Test.Hspec import Test.Hspec.QuickCheck import URI.ByteString (aggressiveNormalization, normalizeURIRef) +import Wire.SamlProtocolSettings +import Wire.SamlProtocolSettings.Servant (sparRouteToServant) spec :: Spec spec = do From 77f097a5393e37c687c6b15d11c3fad81762949e Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Tue, 28 Jul 2026 18:54:30 +0200 Subject: [PATCH 040/113] WPB-23631: Move `Spar.Sem.ScimUserTimesStore` in `Wire.ScimUserTimesStore` (#5378) Move the ScimUserTimesStore effect + Cassandra/Mem backends to Wire.ScimUserTimesStore(.Cassandra/.Mem). Zero spar dependencies (cleanest store). Rewire consumers (CanonicalInterpreter, Spar.API/Scim/Scim.User, integration + unit Test.Spar.Scim.UserSpec); delete old modules; register in cabal. --- changelog.d/5-internal/WPB-23631-2 | 1 + .../wire-subsystems/src/Wire}/ScimUserTimesStore.hs | 2 +- .../src/Wire}/ScimUserTimesStore/Cassandra.hs | 4 ++-- .../wire-subsystems/src/Wire}/ScimUserTimesStore/Mem.hs | 6 +++--- libs/wire-subsystems/wire-subsystems.cabal | 3 +++ services/spar/spar.cabal | 3 --- services/spar/src/Spar/API.hs | 4 ++-- services/spar/src/Spar/CanonicalInterpreter.hs | 4 ++-- services/spar/src/Spar/Scim.hs | 2 +- services/spar/src/Spar/Scim/User.hs | 4 ++-- services/spar/test-integration/Test/Spar/Scim/UserSpec.hs | 2 +- services/spar/test/Test/Spar/Scim/UserSpec.hs | 4 ++-- 12 files changed, 20 insertions(+), 19 deletions(-) create mode 100644 changelog.d/5-internal/WPB-23631-2 rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/ScimUserTimesStore.hs (97%) rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/ScimUserTimesStore/Cassandra.hs (97%) rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/ScimUserTimesStore/Mem.hs (93%) diff --git a/changelog.d/5-internal/WPB-23631-2 b/changelog.d/5-internal/WPB-23631-2 new file mode 100644 index 00000000000..900550a2839 --- /dev/null +++ b/changelog.d/5-internal/WPB-23631-2 @@ -0,0 +1 @@ +Move `Spar.Sem.ScimUserTimesStore` to `Wire.ScimUserTimesStore`. diff --git a/services/spar/src/Spar/Sem/ScimUserTimesStore.hs b/libs/wire-subsystems/src/Wire/ScimUserTimesStore.hs similarity index 97% rename from services/spar/src/Spar/Sem/ScimUserTimesStore.hs rename to libs/wire-subsystems/src/Wire/ScimUserTimesStore.hs index 0efb17527bd..4a13c517e90 100644 --- a/services/spar/src/Spar/Sem/ScimUserTimesStore.hs +++ b/libs/wire-subsystems/src/Wire/ScimUserTimesStore.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.ScimUserTimesStore +module Wire.ScimUserTimesStore ( ScimUserTimesStore (..), write, read, diff --git a/services/spar/src/Spar/Sem/ScimUserTimesStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ScimUserTimesStore/Cassandra.hs similarity index 97% rename from services/spar/src/Spar/Sem/ScimUserTimesStore/Cassandra.hs rename to libs/wire-subsystems/src/Wire/ScimUserTimesStore/Cassandra.hs index bd3c45cbce1..fe669d18136 100644 --- a/services/spar/src/Spar/Sem/ScimUserTimesStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/ScimUserTimesStore/Cassandra.hs @@ -18,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.ScimUserTimesStore.Cassandra +module Wire.ScimUserTimesStore.Cassandra ( scimUserTimesStoreToCassandra, ) where @@ -28,9 +28,9 @@ import Data.Id import Data.Json.Util (UTCTimeMillis, toUTCTimeMillis) import Imports import Polysemy -import Spar.Sem.ScimUserTimesStore (ScimUserTimesStore (..)) import Web.Scim.Schema.Common (WithId (..)) import Web.Scim.Schema.Meta (Meta (..), WithMeta (..)) +import Wire.ScimUserTimesStore (ScimUserTimesStore (..)) scimUserTimesStoreToCassandra :: forall m r a. (MonadClient m, Member (Embed m) r) => Sem (ScimUserTimesStore ': r) a -> Sem r a scimUserTimesStoreToCassandra = diff --git a/services/spar/src/Spar/Sem/ScimUserTimesStore/Mem.hs b/libs/wire-subsystems/src/Wire/ScimUserTimesStore/Mem.hs similarity index 93% rename from services/spar/src/Spar/Sem/ScimUserTimesStore/Mem.hs rename to libs/wire-subsystems/src/Wire/ScimUserTimesStore/Mem.hs index fa5f027d8a4..a1de707e047 100644 --- a/services/spar/src/Spar/Sem/ScimUserTimesStore/Mem.hs +++ b/libs/wire-subsystems/src/Wire/ScimUserTimesStore/Mem.hs @@ -17,20 +17,20 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.ScimUserTimesStore.Mem +module Wire.ScimUserTimesStore.Mem ( scimUserTimesStoreToMem, ) where import Data.Id (UserId) import Data.Json.Util (UTCTimeMillis, toUTCTimeMillis) -import qualified Data.Map as M +import Data.Map qualified as M import Imports import Polysemy import Polysemy.State -import Spar.Sem.ScimUserTimesStore import Web.Scim.Schema.Common (WithId (WithId)) import Web.Scim.Schema.Meta (WithMeta (WithMeta), created, lastModified) +import Wire.ScimUserTimesStore scimUserTimesStoreToMem :: Sem (ScimUserTimesStore ': r) a -> diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 4b4a0927d43..2b805c6e51b 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -422,6 +422,9 @@ library Wire.ScimSubsystem Wire.ScimSubsystem.Error Wire.ScimSubsystem.Interpreter + Wire.ScimUserTimesStore + Wire.ScimUserTimesStore.Cassandra + Wire.ScimUserTimesStore.Mem Wire.ServiceStore Wire.ServiceStore.Cassandra Wire.SessionStore diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal index 6da4c522697..53fa27e9ed3 100644 --- a/services/spar/spar.cabal +++ b/services/spar/spar.cabal @@ -89,9 +89,6 @@ library Spar.Sem.ScimTokenStore Spar.Sem.ScimTokenStore.Cassandra Spar.Sem.ScimTokenStore.Mem - Spar.Sem.ScimUserTimesStore - Spar.Sem.ScimUserTimesStore.Cassandra - Spar.Sem.ScimUserTimesStore.Mem Spar.Sem.Utils Spar.Sem.VerdictFormatStore Spar.Sem.VerdictFormatStore.Cassandra diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index 632c08dd0af..6ac02322e34 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -109,8 +109,6 @@ import qualified Spar.Sem.SAMLUserStore as SAMLUserStore import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import Spar.Sem.ScimTokenStore (ScimTokenStore) import qualified Spar.Sem.ScimTokenStore as ScimTokenStore -import Spar.Sem.ScimUserTimesStore (ScimUserTimesStore) -import qualified Spar.Sem.ScimUserTimesStore as ScimUserTimesStore import Spar.Sem.VerdictFormatStore (VerdictFormatStore) import qualified Spar.Sem.VerdictFormatStore as VerdictFormatStore import System.Logger (Msg) @@ -137,6 +135,8 @@ import Wire.Reporter (Reporter) import Wire.SamlProtocolSettings (SamlProtocolSettings) import qualified Wire.SamlProtocolSettings as SamlProtocolSettings import Wire.ScimSubsystem +import Wire.ScimUserTimesStore (ScimUserTimesStore) +import qualified Wire.ScimUserTimesStore as ScimUserTimesStore import Wire.Sem.Logger (Logger) import qualified Wire.Sem.Logger as Logger import Wire.Sem.Now (Now) diff --git a/services/spar/src/Spar/CanonicalInterpreter.hs b/services/spar/src/Spar/CanonicalInterpreter.hs index 684bcfbac89..f9a09e73340 100644 --- a/services/spar/src/Spar/CanonicalInterpreter.hs +++ b/services/spar/src/Spar/CanonicalInterpreter.hs @@ -53,8 +53,6 @@ import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import Spar.Sem.ScimExternalIdStore.Cassandra (scimExternalIdStoreToCassandra) import Spar.Sem.ScimTokenStore (ScimTokenStore) import Spar.Sem.ScimTokenStore.Cassandra (scimTokenStoreToCassandra) -import Spar.Sem.ScimUserTimesStore (ScimUserTimesStore) -import Spar.Sem.ScimUserTimesStore.Cassandra (scimUserTimesStoreToCassandra) import Spar.Sem.Utils import Spar.Sem.VerdictFormatStore (VerdictFormatStore) import Spar.Sem.VerdictFormatStore.Cassandra (verdictFormatStoreToCassandra) @@ -79,6 +77,8 @@ import Wire.SamlProtocolSettings (SamlProtocolSettings) import Wire.SamlProtocolSettings.Servant (sparRouteToServant) import Wire.ScimSubsystem import Wire.ScimSubsystem.Interpreter +import Wire.ScimUserTimesStore (ScimUserTimesStore) +import Wire.ScimUserTimesStore.Cassandra (scimUserTimesStoreToCassandra) import Wire.Sem.Logger.TinyLog (loggerToTinyLog, stringLoggerToTinyLog) import Wire.Sem.Now (Now) import Wire.Sem.Now.IO (nowToIO) diff --git a/services/spar/src/Spar/Scim.hs b/services/spar/src/Spar/Scim.hs index 0152ca1b02a..e67dfa231d2 100644 --- a/services/spar/src/Spar/Scim.hs +++ b/services/spar/src/Spar/Scim.hs @@ -86,7 +86,6 @@ import Spar.Scim.User import Spar.Sem.SAMLUserStore (SAMLUserStore) import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import Spar.Sem.ScimTokenStore (ScimTokenStore) -import Spar.Sem.ScimUserTimesStore (ScimUserTimesStore) import System.Logger (Msg) import qualified Web.Scim.Capabilities.MetaSchema as Scim.Meta import qualified Web.Scim.Class.Group as Scim.Group @@ -102,6 +101,7 @@ import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.IdPConfigStore (IdPConfigStore) import Wire.Reporter (Reporter) import Wire.ScimSubsystem +import Wire.ScimUserTimesStore (ScimUserTimesStore) import Wire.Sem.Logger (Logger) import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) diff --git a/services/spar/src/Spar/Scim/User.hs b/services/spar/src/Spar/Scim/User.hs index ad530d8412c..b6ebc5dbab1 100644 --- a/services/spar/src/Spar/Scim/User.hs +++ b/services/spar/src/Spar/Scim/User.hs @@ -83,8 +83,6 @@ import Spar.Sem.SAMLUserStore (SAMLUserStore) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore -import Spar.Sem.ScimUserTimesStore (ScimUserTimesStore) -import qualified Spar.Sem.ScimUserTimesStore as ScimUserTimesStore import qualified System.Logger.Class as Log import System.Logger.Message (Msg) import qualified URI.ByteString as URIBS @@ -114,6 +112,8 @@ import Wire.GalleyAPIAccess (GalleyAPIAccess) import qualified Wire.GalleyAPIAccess as GalleyAPIAccess import Wire.IdPConfigStore (IdPConfigStore) import qualified Wire.IdPConfigStore as IdPConfigStore +import Wire.ScimUserTimesStore (ScimUserTimesStore) +import qualified Wire.ScimUserTimesStore as ScimUserTimesStore import Wire.Sem.Logger (Logger) import qualified Wire.Sem.Logger as Logger import Wire.Sem.Now (Now) diff --git a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs index 1df3cd6ff36..1ea57c883b8 100644 --- a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs +++ b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs @@ -70,7 +70,6 @@ import Spar.Scim.Types (normalizeLikeStored) import qualified Spar.Scim.User as SU import qualified Spar.Sem.SAMLUserStore as SAMLUserStore import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore -import qualified Spar.Sem.ScimUserTimesStore as ScimUserTimesStore import Test.Tasty.HUnit ((@?=)) import qualified Text.XML.DSig as SAML import Util @@ -96,6 +95,7 @@ import Wire.API.User.RichInfo import qualified Wire.API.User.Scim as Spar.Types import qualified Wire.API.User.Search as Search import qualified Wire.BrigAPIAccess as BrigAPIAccess +import qualified Wire.ScimUserTimesStore as ScimUserTimesStore -- | Tests for @\/scim\/v2\/Users@. spec :: SpecWith TestEnv diff --git a/services/spar/test/Test/Spar/Scim/UserSpec.hs b/services/spar/test/Test/Spar/Scim/UserSpec.hs index 5088a8fde7d..b8625312528 100644 --- a/services/spar/test/Test/Spar/Scim/UserSpec.hs +++ b/services/spar/test/Test/Spar/Scim/UserSpec.hs @@ -30,8 +30,6 @@ import Spar.Sem.SAMLUserStore import Spar.Sem.SAMLUserStore.Mem (samlUserStoreToMem) import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore import Spar.Sem.ScimExternalIdStore.Mem (scimExternalIdStoreToMem) -import Spar.Sem.ScimUserTimesStore -import Spar.Sem.ScimUserTimesStore.Mem (scimUserTimesStoreToMem) import System.Logger (Msg) import Test.Hspec import Test.QuickCheck @@ -42,6 +40,8 @@ import Wire.BrigAPIAccess import Wire.IdPConfigStore import Wire.IdPConfigStore.Mem (idPToMem) import Wire.IdPConfigStore.Orphans () +import Wire.ScimUserTimesStore +import Wire.ScimUserTimesStore.Mem (scimUserTimesStoreToMem) import Wire.Sem.Logger.TinyLog (discardTinyLogs) spec :: Spec From e2eee36d56793c2cc8263544c65568940de26a67 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 29 Jul 2026 10:45:13 +0200 Subject: [PATCH 041/113] WPB-23631: `Spar.Sem.DefaultSsoCode` in `Wire.DefaultSsoStore` (#5379) Move the DefaultSsoCode effect + Cassandra/Mem backends to Wire.DefaultSsoStore(.Cassandra/.Mem). The Cql SAML.IdPId instance stays a side-effect import of Wire.DomainRegistrationStore.Cassandra (unchanged). Rewire consumers (CanonicalInterpreter, Spar.API, the Spec helper and its unit-test wrapper); delete old modules; register in cabal. Adds the polysemy-check build-dep (used by the .Mem backend's deriveGenericK). --- changelog.d/5-internal/WPB-23631-3 | 1 + libs/wire-subsystems/default.nix | 3 +++ .../wire-subsystems/src/Wire/DefaultSsoStore.hs | 4 ++-- .../src/Wire/DefaultSsoStore}/Cassandra.hs | 11 +++++------ .../wire-subsystems/src/Wire/DefaultSsoStore}/Mem.hs | 6 +++--- libs/wire-subsystems/wire-subsystems.cabal | 5 +++++ services/spar/spar.cabal | 3 --- services/spar/src/Spar/API.hs | 4 ++-- services/spar/src/Spar/CanonicalInterpreter.hs | 4 ++-- services/spar/src/Spar/Sem/DefaultSsoCode/Spec.hs | 2 +- .../spar/test/Test/Spar/Sem/DefaultSsoCodeSpec.hs | 2 +- 11 files changed, 25 insertions(+), 20 deletions(-) create mode 100644 changelog.d/5-internal/WPB-23631-3 rename services/spar/src/Spar/Sem/DefaultSsoCode.hs => libs/wire-subsystems/src/Wire/DefaultSsoStore.hs (94%) rename {services/spar/src/Spar/Sem/DefaultSsoCode => libs/wire-subsystems/src/Wire/DefaultSsoStore}/Cassandra.hs (89%) rename {services/spar/src/Spar/Sem/DefaultSsoCode => libs/wire-subsystems/src/Wire/DefaultSsoStore}/Mem.hs (90%) diff --git a/changelog.d/5-internal/WPB-23631-3 b/changelog.d/5-internal/WPB-23631-3 new file mode 100644 index 00000000000..17144f08e16 --- /dev/null +++ b/changelog.d/5-internal/WPB-23631-3 @@ -0,0 +1 @@ +Move `Spar.Sem.DefaultSsoCode` in `Wire.DefaultSsoStore` diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix index cca2ff50828..38dd6c249bd 100644 --- a/libs/wire-subsystems/default.nix +++ b/libs/wire-subsystems/default.nix @@ -87,6 +87,7 @@ , network-conduit-tls , network-uri , polysemy +, polysemy-check , polysemy-conc , polysemy-plugin , polysemy-time @@ -229,6 +230,7 @@ mkDerivation { network-conduit-tls network-uri polysemy + polysemy-check polysemy-conc polysemy-plugin polysemy-time @@ -362,6 +364,7 @@ mkDerivation { network-conduit-tls network-uri polysemy + polysemy-check polysemy-conc polysemy-plugin polysemy-time diff --git a/services/spar/src/Spar/Sem/DefaultSsoCode.hs b/libs/wire-subsystems/src/Wire/DefaultSsoStore.hs similarity index 94% rename from services/spar/src/Spar/Sem/DefaultSsoCode.hs rename to libs/wire-subsystems/src/Wire/DefaultSsoStore.hs index 5949f859705..ed33349e978 100644 --- a/services/spar/src/Spar/Sem/DefaultSsoCode.hs +++ b/libs/wire-subsystems/src/Wire/DefaultSsoStore.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.DefaultSsoCode +module Wire.DefaultSsoStore ( DefaultSsoCode (..), get, store, @@ -28,7 +28,7 @@ where import Imports import Polysemy import Polysemy.Check (deriveGenericK) -import qualified SAML2.WebSSO as SAML +import SAML2.WebSSO qualified as SAML data DefaultSsoCode m a where Get :: DefaultSsoCode m (Maybe SAML.IdPId) diff --git a/services/spar/src/Spar/Sem/DefaultSsoCode/Cassandra.hs b/libs/wire-subsystems/src/Wire/DefaultSsoStore/Cassandra.hs similarity index 89% rename from services/spar/src/Spar/Sem/DefaultSsoCode/Cassandra.hs rename to libs/wire-subsystems/src/Wire/DefaultSsoStore/Cassandra.hs index 680c8c0ec7c..b1a5f80c538 100644 --- a/services/spar/src/Spar/Sem/DefaultSsoCode/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/DefaultSsoStore/Cassandra.hs @@ -19,7 +19,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.DefaultSsoCode.Cassandra +module Wire.DefaultSsoStore.Cassandra ( defaultSsoCodeToCassandra, ) where @@ -27,10 +27,9 @@ where import Cassandra import Imports import Polysemy -import qualified SAML2.WebSSO.Types as SAML -import Spar.Data.Instances () -import Spar.Sem.DefaultSsoCode -import {- instance Cql SAML.IdPId -} Wire.DomainRegistrationStore.Cassandra () +import SAML2.WebSSO.Types qualified as SAML +import Wire.DefaultSsoStore +import Wire.DomainRegistrationStore.Cassandra () defaultSsoCodeToCassandra :: forall m r a. @@ -65,7 +64,7 @@ storeDefaultSsoCode :: storeDefaultSsoCode idpId = do -- there is a race condition here which means there could potentially be more -- than one entry (violating invariant 2). - -- However, the SELECT query will deterministally pick one of them due to the + -- However, the SELECT query will deterministically pick one of them due to the -- `ORDER BY` clause. The others will get removed by `deleteDefaultSsoCode` -- the next time this function is called (as it removes all entries). deleteDefaultSsoCode diff --git a/services/spar/src/Spar/Sem/DefaultSsoCode/Mem.hs b/libs/wire-subsystems/src/Wire/DefaultSsoStore/Mem.hs similarity index 90% rename from services/spar/src/Spar/Sem/DefaultSsoCode/Mem.hs rename to libs/wire-subsystems/src/Wire/DefaultSsoStore/Mem.hs index c684eaa1a66..f9d9d4d5268 100644 --- a/services/spar/src/Spar/Sem/DefaultSsoCode/Mem.hs +++ b/libs/wire-subsystems/src/Wire/DefaultSsoStore/Mem.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.DefaultSsoCode.Mem +module Wire.DefaultSsoStore.Mem ( defaultSsoCodeToMem, ) where @@ -25,8 +25,8 @@ where import Imports import Polysemy import Polysemy.State (get, put, runState) -import qualified SAML2.WebSSO as SAML -import Spar.Sem.DefaultSsoCode (DefaultSsoCode (..)) +import SAML2.WebSSO qualified as SAML +import Wire.DefaultSsoStore (DefaultSsoCode (..)) defaultSsoCodeToMem :: Sem (DefaultSsoCode ': r) a -> Sem r (Maybe SAML.IdPId, a) defaultSsoCodeToMem = (runState Nothing .) $ diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 2b805c6e51b..b4a6e665cde 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -159,6 +159,7 @@ common common-all , network-conduit-tls , network-uri , polysemy + , polysemy-check , polysemy-conc , polysemy-plugin , polysemy-time @@ -308,6 +309,9 @@ library Wire.ConversationSubsystem.Util Wire.CustomBackendStore Wire.CustomBackendStore.Cassandra + Wire.DefaultSsoStore + Wire.DefaultSsoStore.Cassandra + Wire.DefaultSsoStore.Mem Wire.DeleteQueue Wire.DeleteQueue.InMemory Wire.DomainRegistrationStore @@ -546,6 +550,7 @@ library , network , network-conduit-tls , polysemy + , polysemy-check , polysemy-plugin , polysemy-time , polysemy-wire-zoo diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal index 53fa27e9ed3..319bb84a88f 100644 --- a/services/spar/spar.cabal +++ b/services/spar/spar.cabal @@ -69,9 +69,6 @@ library Spar.Sem.AssIDStore Spar.Sem.AssIDStore.Cassandra Spar.Sem.AssIDStore.Mem - Spar.Sem.DefaultSsoCode - Spar.Sem.DefaultSsoCode.Cassandra - Spar.Sem.DefaultSsoCode.Mem Spar.Sem.DefaultSsoCode.Spec Spar.Sem.IdPRawMetadataStore Spar.Sem.IdPRawMetadataStore.Cassandra diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index 6ac02322e34..56a04e0bd5b 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -98,8 +98,6 @@ import Spar.Orphans () import Spar.Scim hiding (handle) import Spar.Sem.AReqIDStore (AReqIDStore) import Spar.Sem.AssIDStore (AssIDStore) -import Spar.Sem.DefaultSsoCode (DefaultSsoCode) -import qualified Spar.Sem.DefaultSsoCode as DefaultSsoCode import Spar.Sem.IdPRawMetadataStore (IdPRawMetadataStore) import qualified Spar.Sem.IdPRawMetadataStore as IdPRawMetadataStore import Spar.Sem.SAML2 (SAML2) @@ -126,6 +124,8 @@ import Wire.API.User.IdentityProvider import Wire.API.User.Saml import Wire.BrigAPIAccess (BrigAPIAccess) import qualified Wire.BrigAPIAccess as BrigAPIAccess +import Wire.DefaultSsoStore (DefaultSsoCode) +import qualified Wire.DefaultSsoStore as DefaultSsoCode import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.IdPConfigStore (IdPConfigStore, Replaced (..), Replacing (..)) import qualified Wire.IdPConfigStore as IdPConfigStore diff --git a/services/spar/src/Spar/CanonicalInterpreter.hs b/services/spar/src/Spar/CanonicalInterpreter.hs index f9a09e73340..a0794f6723e 100644 --- a/services/spar/src/Spar/CanonicalInterpreter.hs +++ b/services/spar/src/Spar/CanonicalInterpreter.hs @@ -41,8 +41,6 @@ import Spar.Sem.AReqIDStore (AReqIDStore) import Spar.Sem.AReqIDStore.Cassandra (aReqIDStoreToCassandra) import Spar.Sem.AssIDStore (AssIDStore) import Spar.Sem.AssIDStore.Cassandra (assIDStoreToCassandra) -import Spar.Sem.DefaultSsoCode (DefaultSsoCode) -import Spar.Sem.DefaultSsoCode.Cassandra (defaultSsoCodeToCassandra) import Spar.Sem.IdPRawMetadataStore (IdPRawMetadataStore) import Spar.Sem.IdPRawMetadataStore.Cassandra (idpRawMetadataStoreToCassandra) import Spar.Sem.SAML2 (SAML2) @@ -62,6 +60,8 @@ import Wire.API.User.Saml (TTLError) import Wire.BrigAPIAccess import Wire.BrigAPIAccess.Rpc import Wire.ClientSubsystem.Error (ClientError, clientErrorToHttpError) +import Wire.DefaultSsoStore (DefaultSsoCode) +import Wire.DefaultSsoStore.Cassandra (defaultSsoCodeToCassandra) import Wire.GalleyAPIAccess import Wire.GalleyAPIAccess.Rpc import Wire.IdPConfigStore (IdPConfigStore) diff --git a/services/spar/src/Spar/Sem/DefaultSsoCode/Spec.hs b/services/spar/src/Spar/Sem/DefaultSsoCode/Spec.hs index 3f83e9b3459..d1e930d49eb 100644 --- a/services/spar/src/Spar/Sem/DefaultSsoCode/Spec.hs +++ b/services/spar/src/Spar/Sem/DefaultSsoCode/Spec.hs @@ -24,10 +24,10 @@ import Imports import Polysemy import Polysemy.Check import SAML2.WebSSO.Types -import qualified Spar.Sem.DefaultSsoCode as E import Test.Hspec import Test.Hspec.QuickCheck import Test.QuickCheck +import qualified Wire.DefaultSsoStore as E propsForInterpreter :: (PropConstraints r f) => diff --git a/services/spar/test/Test/Spar/Sem/DefaultSsoCodeSpec.hs b/services/spar/test/Test/Spar/Sem/DefaultSsoCodeSpec.hs index 32e703901f8..12767810e6f 100644 --- a/services/spar/test/Test/Spar/Sem/DefaultSsoCodeSpec.hs +++ b/services/spar/test/Test/Spar/Sem/DefaultSsoCodeSpec.hs @@ -24,10 +24,10 @@ module Test.Spar.Sem.DefaultSsoCodeSpec where import Arbitrary () import Imports import Polysemy -import Spar.Sem.DefaultSsoCode.Mem import Spar.Sem.DefaultSsoCode.Spec import Test.Hspec import Test.Hspec.QuickCheck +import Wire.DefaultSsoStore.Mem (defaultSsoCodeToMem) spec :: Spec spec = modifyMaxSuccess (const 1000) $ do From b035a6b32a6140be1a44859fc79d270ee62d4ecd Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 29 Jul 2026 14:56:26 +0200 Subject: [PATCH 042/113] WPB-23631: Move `Spar.Sem.IdPRawMetadataStore` to `Wire.IdPRawMetadataStore` (#5380) Move the IdPRawMetadataStore effect + Cassandra/Mem backends to Wire.IdPRawMetadataStore(.Cassandra/.Mem). The Cql SAML.IdPId instance stays a side-effect import of Wire.DomainRegistrationStore.Cassandra (unchanged). Complementary to Wire.IdPConfigStore (this stores the raw XML blob). Rewire consumers (CanonicalInterpreter, Spar.API, IdPSpec, the Spec helper and unit-test wrapper); delete old modules; register in cabal. --- changelog.d/5-internal/WPB-23631-4 | 1 + .../wire-subsystems/src/Wire}/IdPRawMetadataStore.hs | 4 ++-- .../src/Wire}/IdPRawMetadataStore/Cassandra.hs | 7 +++---- .../wire-subsystems/src/Wire}/IdPRawMetadataStore/Mem.hs | 8 ++++---- libs/wire-subsystems/wire-subsystems.cabal | 3 +++ services/spar/spar.cabal | 3 --- services/spar/src/Spar/API.hs | 4 ++-- services/spar/src/Spar/CanonicalInterpreter.hs | 4 ++-- services/spar/src/Spar/Sem/IdPRawMetadataStore/Spec.hs | 2 +- services/spar/test/Test/Spar/Saml/IdPSpec.hs | 4 ++-- .../spar/test/Test/Spar/Sem/IdPRawMetadataStoreSpec.hs | 4 ++-- 11 files changed, 22 insertions(+), 22 deletions(-) create mode 100644 changelog.d/5-internal/WPB-23631-4 rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/IdPRawMetadataStore.hs (94%) rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/IdPRawMetadataStore/Cassandra.hs (93%) rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/IdPRawMetadataStore/Mem.hs (88%) diff --git a/changelog.d/5-internal/WPB-23631-4 b/changelog.d/5-internal/WPB-23631-4 new file mode 100644 index 00000000000..3fa09714c3c --- /dev/null +++ b/changelog.d/5-internal/WPB-23631-4 @@ -0,0 +1 @@ +Move `Spar.Sem.IdPRawMetadataStore` to `Wire.IdPRawMetadataStore`. diff --git a/services/spar/src/Spar/Sem/IdPRawMetadataStore.hs b/libs/wire-subsystems/src/Wire/IdPRawMetadataStore.hs similarity index 94% rename from services/spar/src/Spar/Sem/IdPRawMetadataStore.hs rename to libs/wire-subsystems/src/Wire/IdPRawMetadataStore.hs index d6425df9ca7..db86bcad1ad 100644 --- a/services/spar/src/Spar/Sem/IdPRawMetadataStore.hs +++ b/libs/wire-subsystems/src/Wire/IdPRawMetadataStore.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.IdPRawMetadataStore +module Wire.IdPRawMetadataStore ( IdPRawMetadataStore (..), store, get, @@ -28,7 +28,7 @@ where import Imports import Polysemy import Polysemy.Check (deriveGenericK) -import qualified SAML2.WebSSO as SAML +import SAML2.WebSSO qualified as SAML data IdPRawMetadataStore m a where Store :: SAML.IdPId -> Text -> IdPRawMetadataStore m () diff --git a/services/spar/src/Spar/Sem/IdPRawMetadataStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/IdPRawMetadataStore/Cassandra.hs similarity index 93% rename from services/spar/src/Spar/Sem/IdPRawMetadataStore/Cassandra.hs rename to libs/wire-subsystems/src/Wire/IdPRawMetadataStore/Cassandra.hs index 812442d958a..8325d739382 100644 --- a/services/spar/src/Spar/Sem/IdPRawMetadataStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/IdPRawMetadataStore/Cassandra.hs @@ -18,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.IdPRawMetadataStore.Cassandra +module Wire.IdPRawMetadataStore.Cassandra ( idpRawMetadataStoreToCassandra, ) where @@ -27,10 +27,9 @@ import Cassandra as Cas import Control.Lens import Imports import Polysemy -import qualified SAML2.WebSSO as SAML -import Spar.Data.Instances () -import Spar.Sem.IdPRawMetadataStore +import SAML2.WebSSO qualified as SAML import {- instance Cql SAML.IdPId -} Wire.DomainRegistrationStore.Cassandra () +import Wire.IdPRawMetadataStore idpRawMetadataStoreToCassandra :: forall m r a. diff --git a/services/spar/src/Spar/Sem/IdPRawMetadataStore/Mem.hs b/libs/wire-subsystems/src/Wire/IdPRawMetadataStore/Mem.hs similarity index 88% rename from services/spar/src/Spar/Sem/IdPRawMetadataStore/Mem.hs rename to libs/wire-subsystems/src/Wire/IdPRawMetadataStore/Mem.hs index ace943c0a3e..d95950a3dc8 100644 --- a/services/spar/src/Spar/Sem/IdPRawMetadataStore/Mem.hs +++ b/libs/wire-subsystems/src/Wire/IdPRawMetadataStore/Mem.hs @@ -17,14 +17,14 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.IdPRawMetadataStore.Mem (idpRawMetadataStoreToMem, RawState) where +module Wire.IdPRawMetadataStore.Mem (idpRawMetadataStoreToMem, RawState) where -import qualified Data.Map as M +import Data.Map qualified as M import Imports import Polysemy import Polysemy.State (State, gets, modify, runState) -import qualified SAML2.WebSSO.Types as SAML -import Spar.Sem.IdPRawMetadataStore +import SAML2.WebSSO.Types qualified as SAML +import Wire.IdPRawMetadataStore type RawState = Map SAML.IdPId Text diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index b4a6e665cde..fe5eb82af98 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -363,6 +363,9 @@ library Wire.IdPConfigStore.Cassandra Wire.IdPConfigStore.Mem Wire.IdPConfigStore.Orphans + Wire.IdPRawMetadataStore + Wire.IdPRawMetadataStore.Cassandra + Wire.IdPRawMetadataStore.Mem Wire.IdPSubsystem Wire.IdPSubsystem.Interpreter Wire.IndexedUserStore diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal index 319bb84a88f..5b0482f74c9 100644 --- a/services/spar/spar.cabal +++ b/services/spar/spar.cabal @@ -70,9 +70,6 @@ library Spar.Sem.AssIDStore.Cassandra Spar.Sem.AssIDStore.Mem Spar.Sem.DefaultSsoCode.Spec - Spar.Sem.IdPRawMetadataStore - Spar.Sem.IdPRawMetadataStore.Cassandra - Spar.Sem.IdPRawMetadataStore.Mem Spar.Sem.IdPRawMetadataStore.Spec Spar.Sem.SAML2 Spar.Sem.SAML2.Library diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index 56a04e0bd5b..de2749a85e1 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -98,8 +98,6 @@ import Spar.Orphans () import Spar.Scim hiding (handle) import Spar.Sem.AReqIDStore (AReqIDStore) import Spar.Sem.AssIDStore (AssIDStore) -import Spar.Sem.IdPRawMetadataStore (IdPRawMetadataStore) -import qualified Spar.Sem.IdPRawMetadataStore as IdPRawMetadataStore import Spar.Sem.SAML2 (SAML2) import qualified Spar.Sem.SAML2 as SAML2 import Spar.Sem.SAMLUserStore (SAMLUserStore) @@ -129,6 +127,8 @@ import qualified Wire.DefaultSsoStore as DefaultSsoCode import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.IdPConfigStore (IdPConfigStore, Replaced (..), Replacing (..)) import qualified Wire.IdPConfigStore as IdPConfigStore +import Wire.IdPRawMetadataStore (IdPRawMetadataStore) +import qualified Wire.IdPRawMetadataStore as IdPRawMetadataStore import Wire.IdPSubsystem (IdPSubsystem) import qualified Wire.IdPSubsystem as IdPSubsystem import Wire.Reporter (Reporter) diff --git a/services/spar/src/Spar/CanonicalInterpreter.hs b/services/spar/src/Spar/CanonicalInterpreter.hs index a0794f6723e..6b5e5fec4c6 100644 --- a/services/spar/src/Spar/CanonicalInterpreter.hs +++ b/services/spar/src/Spar/CanonicalInterpreter.hs @@ -41,8 +41,6 @@ import Spar.Sem.AReqIDStore (AReqIDStore) import Spar.Sem.AReqIDStore.Cassandra (aReqIDStoreToCassandra) import Spar.Sem.AssIDStore (AssIDStore) import Spar.Sem.AssIDStore.Cassandra (assIDStoreToCassandra) -import Spar.Sem.IdPRawMetadataStore (IdPRawMetadataStore) -import Spar.Sem.IdPRawMetadataStore.Cassandra (idpRawMetadataStoreToCassandra) import Spar.Sem.SAML2 (SAML2) import Spar.Sem.SAML2.Library (saml2ToSaml2WebSso) import Spar.Sem.SAMLUserStore (SAMLUserStore) @@ -66,6 +64,8 @@ import Wire.GalleyAPIAccess import Wire.GalleyAPIAccess.Rpc import Wire.IdPConfigStore (IdPConfigStore) import Wire.IdPConfigStore.Cassandra (idPToCassandra) +import Wire.IdPRawMetadataStore (IdPRawMetadataStore) +import Wire.IdPRawMetadataStore.Cassandra (idpRawMetadataStoreToCassandra) import Wire.IdPSubsystem (IdPSubsystem) import Wire.IdPSubsystem.Interpreter (IdPSubsystemError, interpretIdPSubsystem) import Wire.ParseException (ParseException, parseExceptionToHttpError) diff --git a/services/spar/src/Spar/Sem/IdPRawMetadataStore/Spec.hs b/services/spar/src/Spar/Sem/IdPRawMetadataStore/Spec.hs index 1a8805e8afe..1f166a7fcd8 100644 --- a/services/spar/src/Spar/Sem/IdPRawMetadataStore/Spec.hs +++ b/services/spar/src/Spar/Sem/IdPRawMetadataStore/Spec.hs @@ -24,10 +24,10 @@ import Imports import Polysemy import Polysemy.Check import SAML2.WebSSO.Types (IdPId) -import qualified Spar.Sem.IdPRawMetadataStore as E import Test.Hspec import Test.Hspec.QuickCheck import Test.QuickCheck +import qualified Wire.IdPRawMetadataStore as E class (Arbitrary IdPId, CoArbitrary IdPId, Arbitrary Text, CoArbitrary Text, Functor f, Member E.IdPRawMetadataStore r, forall z. (Show z) => Show (f z), forall z. (Eq z) => Eq (f z)) => diff --git a/services/spar/test/Test/Spar/Saml/IdPSpec.hs b/services/spar/test/Test/Spar/Saml/IdPSpec.hs index 4816fb3cb35..4211512e36b 100644 --- a/services/spar/test/Test/Spar/Saml/IdPSpec.hs +++ b/services/spar/test/Test/Spar/Saml/IdPSpec.hs @@ -34,8 +34,6 @@ import Spar.Options (CertFingerprintAllowlist (CertFingerprintAllowlist)) import qualified Spar.Options import Spar.Sem.AReqIDStore (AReqIDStore (..)) import Spar.Sem.AssIDStore (AssIDStore (..)) -import Spar.Sem.IdPRawMetadataStore -import Spar.Sem.IdPRawMetadataStore.Mem import Spar.Sem.SAML2 (SAML2 (..)) import Spar.Sem.SAMLUserStore import qualified Spar.Sem.SAMLUserStore as SAMLUserStore @@ -66,6 +64,8 @@ import Wire.GalleyAPIAccess (GalleyAPIAccess) import qualified Wire.GalleyAPIAccess import Wire.IdPConfigStore import Wire.IdPConfigStore.Mem +import Wire.IdPRawMetadataStore +import Wire.IdPRawMetadataStore.Mem import Wire.Reporter (Reporter (..)) import Wire.SamlProtocolSettings (SamlProtocolSettings) import Wire.SamlProtocolSettings.Servant (sparRouteToServant) diff --git a/services/spar/test/Test/Spar/Sem/IdPRawMetadataStoreSpec.hs b/services/spar/test/Test/Spar/Sem/IdPRawMetadataStoreSpec.hs index 36abf4ea039..a85921cdb57 100644 --- a/services/spar/test/Test/Spar/Sem/IdPRawMetadataStoreSpec.hs +++ b/services/spar/test/Test/Spar/Sem/IdPRawMetadataStoreSpec.hs @@ -22,11 +22,11 @@ module Test.Spar.Sem.IdPRawMetadataStoreSpec where import Arbitrary () import Imports import Polysemy -import qualified Spar.Sem.IdPRawMetadataStore as E -import Spar.Sem.IdPRawMetadataStore.Mem import Spar.Sem.IdPRawMetadataStore.Spec import Test.Hspec import Test.Hspec.QuickCheck +import qualified Wire.IdPRawMetadataStore as E +import Wire.IdPRawMetadataStore.Mem testInterpreter :: Sem '[E.IdPRawMetadataStore] a -> IO (RawState, a) testInterpreter = pure . run . idpRawMetadataStoreToMem From 10085cf2f717065434fca112bd3a8941574b6a62 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 29 Jul 2026 16:01:50 +0200 Subject: [PATCH 043/113] WPB-26626: hide meeting conversations from legacy (< V16) GET endpoints (#5382) Legacy (< V16) single-conversation GETs returned meetings with group_conv_type: null instead of hiding them, inconsistent with the list endpoint (which already drops meetings). The legacy batch endpoint GET /conversations?ids= (Until V3) had the same leak. Fix in Galley.API.Public.Conversation: single legacy GETs (get-unqualified-conversation, -legalhold-alias, get-conversation@v2/v5/v9/v15) now throw ConvNotFound (404 no-conversation) for meetings via legacyOwnConversation/legacyConversation guards; the legacy batch get-conversations filters meetings out of the list. V16+ get-conversation is unchanged (200, group_conv_type: meeting). Integration test updated; changelog added. --- .../wpb-26626-meeting-legacy-404 | 1 + integration/test/API/Galley.hs | 5 +++ integration/test/Test/Conversation.hs | 22 ++++++++--- .../src/Galley/API/Public/Conversation.hs | 39 +++++++++++++++---- 4 files changed, 54 insertions(+), 13 deletions(-) create mode 100644 changelog.d/1-api-changes/wpb-26626-meeting-legacy-404 diff --git a/changelog.d/1-api-changes/wpb-26626-meeting-legacy-404 b/changelog.d/1-api-changes/wpb-26626-meeting-legacy-404 new file mode 100644 index 00000000000..3cdf8fa9758 --- /dev/null +++ b/changelog.d/1-api-changes/wpb-26626-meeting-legacy-404 @@ -0,0 +1 @@ ++`GET /conversations/{domain}/{id}` and the legacy `GET /conversations/{id}` now return 404 (`no-conversation`) when the conversation is a meeting, on API versions prior to V16, instead of returning the conversation with `group_conv_type: null`. The legacy batch endpoint `GET /conversations?ids=…` — itself removed at V3, so only ever available on V1–V2 — likewise omits meeting conversations from its results. Meeting conversations remain fully accessible from V16 onwards. (WPB-26626) diff --git a/integration/test/API/Galley.hs b/integration/test/API/Galley.hs index a316aa9e309..3d7aeab1754 100644 --- a/integration/test/API/Galley.hs +++ b/integration/test/API/Galley.hs @@ -311,6 +311,11 @@ listConversationsVersioned version user cnvs = do $ req & addJSONObject ["qualified_ids" .= cnvs] +getConversationsVersioned :: (MakesValue user) => Versioned -> user -> String -> App Response +getConversationsVersioned version user ids = do + req <- baseRequest user Galley version "/conversations" + submit "GET" $ addQueryParams [("ids", ids)] req + getMLSPublicKeys :: (HasCallStack, MakesValue user) => user -> App Response getMLSPublicKeys user = do req <- baseRequest user Galley Versioned "/mls/public-keys" diff --git a/integration/test/Test/Conversation.hs b/integration/test/Test/Conversation.hs index 0b7dc6a7d28..3dd5615c667 100644 --- a/integration/test/Test/Conversation.hs +++ b/integration/test/Test/Conversation.hs @@ -1302,23 +1302,35 @@ testMeetingGroupConvTypeHiddenInLegacy = do postConversation owner (defProteus {team = Just tid, groupConvType = Just "meeting"}) >>= getJSON 201 convQid <- conv %. "qualified_id" + convIdStr <- convQid %. "id" & asString - -- getConversation: V15 (legacy) omits group_conv_type for meetings, - -- V16+ exposes it. + -- getConversation: V15 (legacy) hides meeting conversations entirely (404); + -- V9 (legacy, OwnConversation-shaped response) hides them too; V16+ exposes them. bindResponse (getConversationVersioned (ExplicitVersion 15) owner conv) $ \resp -> do - resp.status `shouldMatchInt` 200 - resp.json %. "group_conv_type" `shouldMatch` Null + resp.status `shouldMatchInt` 404 + resp.json %. "label" `shouldMatch` ("no-conversation" :: String) + + bindResponse (getConversationVersioned (ExplicitVersion 9) owner conv) $ \resp -> do + resp.status `shouldMatchInt` 404 + resp.json %. "label" `shouldMatch` ("no-conversation" :: String) bindResponse (getConversation owner conv) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "group_conv_type" `shouldMatch` ("meeting" :: String) + -- getConversations (legacy V1/V2 batch GET by ids): meeting conversations are + -- filtered out of the returned list, consistent with the single-GET hide. + bindResponse (getConversationsVersioned (ExplicitVersion 2) owner convIdStr) $ \resp -> do + resp.status `shouldMatchInt` 200 + found <- resp.json %. "conversations" & asList + shouldBeEmpty found + -- listConversations: V15 (legacy) excludes meeting conversations from found, -- V16+ includes them. bindResponse (listConversationsVersioned (ExplicitVersion 15) owner [convQid]) $ \resp -> do resp.status `shouldMatchInt` 200 found <- resp.json %. "found" & asList - length (found :: [Value]) `shouldMatchInt` 0 + shouldBeEmpty found bindResponse (listConversations owner [convQid]) $ \resp -> do resp.status `shouldMatchInt` 200 diff --git a/services/galley/src/Galley/API/Public/Conversation.hs b/services/galley/src/Galley/API/Public/Conversation.hs index 55c7b16d4fd..22210867583 100644 --- a/services/galley/src/Galley/API/Public/Conversation.hs +++ b/services/galley/src/Galley/API/Public/Conversation.hs @@ -20,7 +20,10 @@ module Galley.API.Public.Conversation where import Data.Qualified import Galley.App import Imports -import Wire.API.Conversation +import Polysemy +import Wire.API.Conversation hiding (Member) +import Wire.API.Error +import Wire.API.Error.Galley import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.Conversation import Wire.ConversationStore.MLS.Types @@ -28,19 +31,19 @@ import Wire.ConversationSubsystem conversationAPI :: API ConversationAPI GalleyEffects conversationAPI = - mkNamedAPI @"get-unqualified-conversation" (\lusr cnv -> toLegacyOwnConversation <$> getUnqualifiedOwnConversation lusr cnv) - <@> mkNamedAPI @"get-unqualified-conversation-legalhold-alias" (\lusr cnv -> toLegacyOwnConversation <$> getUnqualifiedOwnConversation lusr cnv) - <@> mkNamedAPI @"get-conversation@v2" (\lusr cnv -> toLegacyOwnConversation <$> getOwnConversation lusr cnv) - <@> mkNamedAPI @"get-conversation@v5" (\lusr cnv -> toLegacyOwnConversation <$> getOwnConversation lusr cnv) - <@> mkNamedAPI @"get-conversation@v9" (\lusr cnv -> toLegacyOwnConversation <$> getOwnConversation lusr cnv) - <@> mkNamedAPI @"get-conversation@v15" (\lusr cnv -> toLegacyConversation <$> getConversation lusr cnv) + mkNamedAPI @"get-unqualified-conversation" (\lusr cnv -> legacyOwnConversation =<< getUnqualifiedOwnConversation lusr cnv) + <@> mkNamedAPI @"get-unqualified-conversation-legalhold-alias" (\lusr cnv -> legacyOwnConversation =<< getUnqualifiedOwnConversation lusr cnv) + <@> mkNamedAPI @"get-conversation@v2" (\lusr cnv -> legacyOwnConversation =<< getOwnConversation lusr cnv) + <@> mkNamedAPI @"get-conversation@v5" (\lusr cnv -> legacyOwnConversation =<< getOwnConversation lusr cnv) + <@> mkNamedAPI @"get-conversation@v9" (\lusr cnv -> legacyOwnConversation =<< getOwnConversation lusr cnv) + <@> mkNamedAPI @"get-conversation@v15" (\lusr cnv -> legacyConversation =<< getConversation lusr cnv) <@> mkNamedAPI @"get-conversation" getConversation <@> mkNamedAPI @"get-conversation-roles" getConversationRoles <@> mkNamedAPI @"get-group-info" getGroupInfo <@> mkNamedAPI @"list-conversation-ids-unqualified" conversationIdsPageFromUnqualified <@> mkNamedAPI @"list-conversation-ids-v2" (conversationIdsPaginated DoNotListGlobalSelf) <@> mkNamedAPI @"list-conversation-ids" conversationIdsPageFrom - <@> mkNamedAPI @"get-conversations" (\lusr mids mstart msize -> (\cl -> ConversationList (map toLegacyOwnConversation cl.convList) cl.convHasMore) <$> getPaginatedConversations lusr mids mstart msize) + <@> mkNamedAPI @"get-conversations" (\lusr mids mstart msize -> (\cl -> ConversationList (map toLegacyOwnConversation (filter (not . isMeetingConversation) cl.convList)) cl.convHasMore) <$> getPaginatedConversations lusr mids mstart msize) <@> mkNamedAPI @"list-conversations@v1" (\lusr req -> toLegacyConversationsResponse <$> listConversations lusr req) <@> mkNamedAPI @"list-conversations@v2" (\lusr req -> toLegacyConversationsResponse <$> listConversations lusr req) <@> mkNamedAPI @"list-conversations@v5" (\lusr req -> toLegacyConversationsResponse <$> listConversations lusr req) @@ -115,3 +118,23 @@ toLegacyCGRV9 :: toLegacyCGRV9 = \case GroupConversationExistedV9 conv -> GroupConversationExistedV9 (toLegacyOwnConversation conv) GroupConversationCreatedV9 cgoc -> GroupConversationCreatedV9 (toLegacyCreateGroupOwnConversation cgoc) + +-- | Convert an own-conversation to the legacy (< V16) wire type, hiding meeting +-- conversations entirely (they have no legacy representation): a meeting yields +-- 'ConvNotFound' (404) rather than leaking @group_conv_type: null@. (WPB-26626) +legacyOwnConversation :: + (Member (ErrorS 'ConvNotFound) r) => + OwnConversation GroupConvType -> + Sem r (OwnConversation GroupConvTypeLegacy) +legacyOwnConversation conv = do + when (isMeetingConversation conv) $ throwS @'ConvNotFound + pure (toLegacyOwnConversation conv) + +-- | As 'legacyOwnConversation', for the full 'Conversation' view (V10-V15 routes). +legacyConversation :: + (Member (ErrorS 'ConvNotFound) r) => + Conversation GroupConvType -> + Sem r (Conversation GroupConvTypeLegacy) +legacyConversation conv = do + when (conv.metadata.cnvmGroupConvType == Just MeetingConversation) $ throwS @'ConvNotFound + pure (toLegacyConversation conv) From b1381a35ada91e64b15ded6670f31777dcf8ebd5 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Fri, 24 Jul 2026 16:34:04 +0200 Subject: [PATCH 044/113] WPB-23631: Move `Spar.Sem.ScimExternalIdStore` in `Wire.ScimExternalIdStore` Move the ScimExternalIdStore effect + Cassandra/Mem backends to Wire.ScimExternalIdStore(.Cassandra/.Mem). Carry-along type move in the same commit (diff-ordered before the port): ScimUserCreationStatus moves from Spar.Scim.Types to Wire.API.User.Scim (constructors + Arbitrary instance preserved verbatim), and its Cql instance folds from Spar.Data.Instances into Wire.ScimExternalIdStore.Cassandra. Rewire all consumers (CanonicalInterpreter, Spar.API/App/Scim/Scim.User, the Spec helper + unit wrapper, integration UserSpec + Util.Core, test Arbitrary); delete old modules; register in cabal. --- changelog.d/5-internal/WPB-23631-6 | 1 + libs/wire-api/src/Wire/API/User/Scim.hs | 8 ++++++- .../src/Wire}/ScimExternalIdStore.hs | 3 +-- .../Wire}/ScimExternalIdStore/Cassandra.hs | 23 +++++++++++++++---- .../src/Wire}/ScimExternalIdStore/Mem.hs | 9 ++++---- libs/wire-subsystems/wire-subsystems.cabal | 3 +++ services/spar/spar.cabal | 3 --- services/spar/src/Spar/API.hs | 2 +- services/spar/src/Spar/App.hs | 4 ++-- .../spar/src/Spar/CanonicalInterpreter.hs | 4 ++-- services/spar/src/Spar/Data/Instances.hs | 13 ----------- services/spar/src/Spar/Scim.hs | 2 +- services/spar/src/Spar/Scim/Types.hs | 8 ------- services/spar/src/Spar/Scim/User.hs | 6 ++--- .../src/Spar/Sem/ScimExternalIdStore/Spec.hs | 4 ++-- .../Test/Spar/Scim/UserSpec.hs | 2 +- services/spar/test-integration/Util/Core.hs | 2 +- services/spar/test/Arbitrary.hs | 1 - services/spar/test/Test/Spar/Scim/UserSpec.hs | 4 ++-- .../Test/Spar/Sem/ScimExternalIdStoreSpec.hs | 2 +- 20 files changed, 50 insertions(+), 54 deletions(-) create mode 100644 changelog.d/5-internal/WPB-23631-6 rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/ScimExternalIdStore.hs (97%) rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/ScimExternalIdStore/Cassandra.hs (85%) rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/ScimExternalIdStore/Mem.hs (88%) diff --git a/changelog.d/5-internal/WPB-23631-6 b/changelog.d/5-internal/WPB-23631-6 new file mode 100644 index 00000000000..527fc5d0fab --- /dev/null +++ b/changelog.d/5-internal/WPB-23631-6 @@ -0,0 +1 @@ +Move `Spar.Sem.ScimExternalIdStore` in `Wire.ScimExternalIdStore`. diff --git a/libs/wire-api/src/Wire/API/User/Scim.hs b/libs/wire-api/src/Wire/API/User/Scim.hs index 8ca092eed10..cda70e95803 100644 --- a/libs/wire-api/src/Wire/API/User/Scim.hs +++ b/libs/wire-api/src/Wire/API/User/Scim.hs @@ -70,7 +70,7 @@ import Imports import SAML2.WebSSO qualified as SAML import SAML2.WebSSO.Test.Arbitrary () import Servant.API (FromHttpApiData (..), ToHttpApiData (..)) -import Test.QuickCheck (Gen) +import Test.QuickCheck (Gen, elements) import Test.QuickCheck qualified as QC import Web.HttpApiData (parseHeaderWithPrefix) import Web.Scim.AttrName (AttrName (..)) @@ -515,3 +515,9 @@ newtype ScimTokenName = ScimTokenName {fromScimTokenName :: Text} instance ToSchema ScimTokenName where schema = object $ ScimTokenName <$> fromScimTokenName .= field "name" schema + +data ScimUserCreationStatus = ScimUserCreating | ScimUserCreated + deriving (Eq, Show, Generic) + +instance Arbitrary ScimUserCreationStatus where + arbitrary = elements [ScimUserCreating, ScimUserCreated] diff --git a/services/spar/src/Spar/Sem/ScimExternalIdStore.hs b/libs/wire-subsystems/src/Wire/ScimExternalIdStore.hs similarity index 97% rename from services/spar/src/Spar/Sem/ScimExternalIdStore.hs rename to libs/wire-subsystems/src/Wire/ScimExternalIdStore.hs index c4bb2b54ed6..f88533358a4 100644 --- a/services/spar/src/Spar/Sem/ScimExternalIdStore.hs +++ b/libs/wire-subsystems/src/Wire/ScimExternalIdStore.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.ScimExternalIdStore +module Wire.ScimExternalIdStore ( ScimExternalIdStore (..), insert, lookup, @@ -32,7 +32,6 @@ import Data.Text import Imports (Maybe, Show) import Polysemy import Polysemy.Check (deriveGenericK) -import Spar.Scim.Types import Wire.API.User.Scim data ScimExternalIdStore m a where diff --git a/services/spar/src/Spar/Sem/ScimExternalIdStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Cassandra.hs similarity index 85% rename from services/spar/src/Spar/Sem/ScimExternalIdStore/Cassandra.hs rename to libs/wire-subsystems/src/Wire/ScimExternalIdStore/Cassandra.hs index 42d098dfe33..573a1df51bd 100644 --- a/services/spar/src/Spar/Sem/ScimExternalIdStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Cassandra.hs @@ -1,3 +1,4 @@ +{-# OPTIONS_GHC -Wno-orphans #-} {-# OPTIONS_GHC -Wno-redundant-constraints #-} -- This file is part of the Wire Server implementation. @@ -17,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.ScimExternalIdStore.Cassandra +module Wire.ScimExternalIdStore.Cassandra ( scimExternalIdStoreToCassandra, ) where @@ -27,10 +28,22 @@ import Data.Bifunctor (second) import Data.Id import Imports import Polysemy -import Spar.Data.Instances () -import Spar.Scim.Types (ScimUserCreationStatus (ScimUserCreated)) -import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore (..)) -import Wire.API.User.Scim (ValidScimId (..)) +import Wire.API.User.Scim (ScimUserCreationStatus (..), ValidScimId (..)) +import Wire.ScimExternalIdStore (ScimExternalIdStore (..)) + +-- Moved from Spar.Data.Instances: this is the only consumer of the +-- @scim_external.creation_status@ column, so the Cql instance lives here. +instance Cql ScimUserCreationStatus where + ctype = Tagged IntColumn + + toCql ScimUserCreated = CqlInt 0 + toCql ScimUserCreating = CqlInt 1 + + fromCql (CqlInt i) = case i of + 0 -> pure ScimUserCreated + 1 -> pure ScimUserCreating + n -> Left $ "unexpected ScimUserCreationStatus: " ++ show n + fromCql _ = Left "int expected" scimExternalIdStoreToCassandra :: forall m r a. diff --git a/services/spar/src/Spar/Sem/ScimExternalIdStore/Mem.hs b/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Mem.hs similarity index 88% rename from services/spar/src/Spar/Sem/ScimExternalIdStore/Mem.hs rename to libs/wire-subsystems/src/Wire/ScimExternalIdStore/Mem.hs index 5ab14ccd4af..7f35b69d7f3 100644 --- a/services/spar/src/Spar/Sem/ScimExternalIdStore/Mem.hs +++ b/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Mem.hs @@ -17,19 +17,18 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.ScimExternalIdStore.Mem +module Wire.ScimExternalIdStore.Mem ( scimExternalIdStoreToMem, ) where import Data.Id (TeamId, UserId) -import qualified Data.Map as M +import Data.Map qualified as M import Imports import Polysemy import Polysemy.State -import Spar.Scim.Types (ScimUserCreationStatus) -import Spar.Sem.ScimExternalIdStore -import Wire.API.User.Scim (ValidScimId (..)) +import Wire.API.User.Scim (ScimUserCreationStatus, ValidScimId (..)) +import Wire.ScimExternalIdStore scimExternalIdStoreToMem :: Sem (ScimExternalIdStore ': r) a -> diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index fe5eb82af98..f60f6b2af76 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -426,6 +426,9 @@ library Wire.SAMLEmailSubsystem.Interpreter Wire.SamlProtocolSettings Wire.SamlProtocolSettings.Servant + Wire.ScimExternalIdStore + Wire.ScimExternalIdStore.Cassandra + Wire.ScimExternalIdStore.Mem Wire.ScimSubsystem Wire.ScimSubsystem.Error Wire.ScimSubsystem.Interpreter diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal index 5b0482f74c9..f177270ca7d 100644 --- a/services/spar/spar.cabal +++ b/services/spar/spar.cabal @@ -76,9 +76,6 @@ library Spar.Sem.SAMLUserStore Spar.Sem.SAMLUserStore.Cassandra Spar.Sem.SAMLUserStore.Mem - Spar.Sem.ScimExternalIdStore - Spar.Sem.ScimExternalIdStore.Cassandra - Spar.Sem.ScimExternalIdStore.Mem Spar.Sem.ScimExternalIdStore.Spec Spar.Sem.ScimTokenStore Spar.Sem.ScimTokenStore.Cassandra diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index de2749a85e1..b5a86098e6c 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -102,7 +102,6 @@ import Spar.Sem.SAML2 (SAML2) import qualified Spar.Sem.SAML2 as SAML2 import Spar.Sem.SAMLUserStore (SAMLUserStore) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore -import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import Spar.Sem.ScimTokenStore (ScimTokenStore) import qualified Spar.Sem.ScimTokenStore as ScimTokenStore import Spar.Sem.VerdictFormatStore (VerdictFormatStore) @@ -134,6 +133,7 @@ import qualified Wire.IdPSubsystem as IdPSubsystem import Wire.Reporter (Reporter) import Wire.SamlProtocolSettings (SamlProtocolSettings) import qualified Wire.SamlProtocolSettings as SamlProtocolSettings +import Wire.ScimExternalIdStore (ScimExternalIdStore) import Wire.ScimSubsystem import Wire.ScimUserTimesStore (ScimUserTimesStore) import qualified Wire.ScimUserTimesStore as ScimUserTimesStore diff --git a/services/spar/src/Spar/App.hs b/services/spar/src/Spar/App.hs index b80baf1c0ac..5949eef21cb 100644 --- a/services/spar/src/Spar/App.hs +++ b/services/spar/src/Spar/App.hs @@ -77,8 +77,6 @@ import Spar.Orphans () import Spar.Sem.AReqIDStore (AReqIDStore) import Spar.Sem.SAMLUserStore (SAMLUserStore) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore -import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) -import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore import Spar.Sem.ScimTokenStore (ScimTokenStore) import qualified Spar.Sem.ScimTokenStore as ScimTokenStore import Spar.Sem.VerdictFormatStore (VerdictFormatStore) @@ -100,6 +98,8 @@ import Wire.IdPConfigStore (IdPConfigStore) import qualified Wire.IdPConfigStore as IdPConfigStore import Wire.Reporter (Reporter) import qualified Wire.Reporter as Reporter +import Wire.ScimExternalIdStore (ScimExternalIdStore) +import qualified Wire.ScimExternalIdStore as ScimExternalIdStore import Wire.ScimSubsystem.Interpreter import Wire.Sem.Logger (Logger) import qualified Wire.Sem.Logger as Logger diff --git a/services/spar/src/Spar/CanonicalInterpreter.hs b/services/spar/src/Spar/CanonicalInterpreter.hs index 6b5e5fec4c6..b2a4405b850 100644 --- a/services/spar/src/Spar/CanonicalInterpreter.hs +++ b/services/spar/src/Spar/CanonicalInterpreter.hs @@ -45,8 +45,6 @@ import Spar.Sem.SAML2 (SAML2) import Spar.Sem.SAML2.Library (saml2ToSaml2WebSso) import Spar.Sem.SAMLUserStore (SAMLUserStore) import Spar.Sem.SAMLUserStore.Cassandra (samlUserStoreToCassandra) -import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) -import Spar.Sem.ScimExternalIdStore.Cassandra (scimExternalIdStoreToCassandra) import Spar.Sem.ScimTokenStore (ScimTokenStore) import Spar.Sem.ScimTokenStore.Cassandra (scimTokenStoreToCassandra) import Spar.Sem.Utils @@ -75,6 +73,8 @@ import Wire.Rpc (Rpc, runRpcWithHttp) import Wire.RpcException import Wire.SamlProtocolSettings (SamlProtocolSettings) import Wire.SamlProtocolSettings.Servant (sparRouteToServant) +import Wire.ScimExternalIdStore (ScimExternalIdStore) +import Wire.ScimExternalIdStore.Cassandra (scimExternalIdStoreToCassandra) import Wire.ScimSubsystem import Wire.ScimSubsystem.Interpreter import Wire.ScimUserTimesStore (ScimUserTimesStore) diff --git a/services/spar/src/Spar/Data/Instances.hs b/services/spar/src/Spar/Data/Instances.hs index 1bd89c0c377..2aec0f7602d 100644 --- a/services/spar/src/Spar/Data/Instances.hs +++ b/services/spar/src/Spar/Data/Instances.hs @@ -38,7 +38,6 @@ import Data.Functor.Alt (Alt (())) import qualified Data.Text.Encoding as T import Data.Text.Encoding.Error import Imports -import Spar.Scim.Types (ScimUserCreationStatus (..)) import URI.ByteString import Wire.API.User.Auth import Wire.API.User.Saml @@ -90,15 +89,3 @@ instance Cql ScimTokenLookupKey where (ScimTokenLookupKeyHashed <$> fromCql s) (ScimTokenLookupKeyPlaintext <$> fromCql s) fromCql _ = Left "ScimTokenLookupKey: expected CqlText" - -instance Cql ScimUserCreationStatus where - ctype = Tagged IntColumn - - toCql ScimUserCreated = CqlInt 0 - toCql ScimUserCreating = CqlInt 1 - - fromCql (CqlInt i) = case i of - 0 -> pure ScimUserCreated - 1 -> pure ScimUserCreating - n -> Left $ "unexpected ScimUserCreationStatus: " ++ show n - fromCql _ = Left "int expected" diff --git a/services/spar/src/Spar/Scim.hs b/services/spar/src/Spar/Scim.hs index e67dfa231d2..02e6b5a60c6 100644 --- a/services/spar/src/Spar/Scim.hs +++ b/services/spar/src/Spar/Scim.hs @@ -84,7 +84,6 @@ import Spar.Scim.Auth import Spar.Scim.Group () import Spar.Scim.User import Spar.Sem.SAMLUserStore (SAMLUserStore) -import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import Spar.Sem.ScimTokenStore (ScimTokenStore) import System.Logger (Msg) import qualified Web.Scim.Capabilities.MetaSchema as Scim.Meta @@ -100,6 +99,7 @@ import Wire.BrigAPIAccess (BrigAPIAccess) import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.IdPConfigStore (IdPConfigStore) import Wire.Reporter (Reporter) +import Wire.ScimExternalIdStore (ScimExternalIdStore) import Wire.ScimSubsystem import Wire.ScimUserTimesStore (ScimUserTimesStore) import Wire.Sem.Logger (Logger) diff --git a/services/spar/src/Spar/Scim/Types.hs b/services/spar/src/Spar/Scim/Types.hs index b2b6b360af7..abda3fb9a81 100644 --- a/services/spar/src/Spar/Scim/Types.hs +++ b/services/spar/src/Spar/Scim/Types.hs @@ -32,8 +32,6 @@ module Spar.Scim.Types where import Control.Lens (view) import Imports -import Test.QuickCheck (Arbitrary (..)) -import Test.QuickCheck.Gen (elements) import qualified Web.Scim.Schema.Common as Scim import qualified Web.Scim.Schema.User as Scim.User import Wire.API.User (AccountStatus (..)) @@ -89,9 +87,3 @@ normalizeLikeStored usr = tweakActive :: Maybe Scim.ScimBool -> Maybe Scim.ScimBool tweakActive = Just . Scim.ScimBool . maybe True Scim.unScimBool - -data ScimUserCreationStatus = ScimUserCreating | ScimUserCreated - deriving (Eq, Show, Generic) - -instance Arbitrary ScimUserCreationStatus where - arbitrary = elements [ScimUserCreating, ScimUserCreated] diff --git a/services/spar/src/Spar/Scim/User.hs b/services/spar/src/Spar/Scim/User.hs index b6ebc5dbab1..a8b54e2de02 100644 --- a/services/spar/src/Spar/Scim/User.hs +++ b/services/spar/src/Spar/Scim/User.hs @@ -81,8 +81,6 @@ import Spar.Scim.Types import qualified Spar.Scim.Types as ST import Spar.Sem.SAMLUserStore (SAMLUserStore) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore -import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) -import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore import qualified System.Logger.Class as Log import System.Logger.Message (Msg) import qualified URI.ByteString as URIBS @@ -104,7 +102,7 @@ import Wire.API.Team.Role import Wire.API.User import Wire.API.User.IdentityProvider (IdP) import qualified Wire.API.User.RichInfo as RI -import Wire.API.User.Scim (ScimTokenInfo (..), ValidScimId (..)) +import Wire.API.User.Scim (ScimTokenInfo (..), ScimUserCreationStatus (..), ValidScimId (..)) import qualified Wire.API.User.Scim as ST import Wire.BrigAPIAccess (BrigAPIAccess) import qualified Wire.BrigAPIAccess as BrigAPIAccess @@ -112,6 +110,8 @@ import Wire.GalleyAPIAccess (GalleyAPIAccess) import qualified Wire.GalleyAPIAccess as GalleyAPIAccess import Wire.IdPConfigStore (IdPConfigStore) import qualified Wire.IdPConfigStore as IdPConfigStore +import Wire.ScimExternalIdStore (ScimExternalIdStore) +import qualified Wire.ScimExternalIdStore as ScimExternalIdStore import Wire.ScimUserTimesStore (ScimUserTimesStore) import qualified Wire.ScimUserTimesStore as ScimUserTimesStore import Wire.Sem.Logger (Logger) diff --git a/services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs b/services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs index eab1ba7d47f..d702e10ab62 100644 --- a/services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs +++ b/services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs @@ -24,11 +24,11 @@ import Data.Id import Imports import Polysemy import Polysemy.Check -import Spar.Scim.Types (ScimUserCreationStatus) -import qualified Spar.Sem.ScimExternalIdStore as E import Test.Hspec import Test.Hspec.QuickCheck import Test.QuickCheck +import Wire.API.User.Scim (ScimUserCreationStatus) +import qualified Wire.ScimExternalIdStore as E propsForInterpreter :: (PropConstraints r f) => diff --git a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs index 1ea57c883b8..545f1ff8977 100644 --- a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs +++ b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs @@ -69,7 +69,6 @@ import Spar.Scim import Spar.Scim.Types (normalizeLikeStored) import qualified Spar.Scim.User as SU import qualified Spar.Sem.SAMLUserStore as SAMLUserStore -import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore import Test.Tasty.HUnit ((@?=)) import qualified Text.XML.DSig as SAML import Util @@ -95,6 +94,7 @@ import Wire.API.User.RichInfo import qualified Wire.API.User.Scim as Spar.Types import qualified Wire.API.User.Search as Search import qualified Wire.BrigAPIAccess as BrigAPIAccess +import qualified Wire.ScimExternalIdStore as ScimExternalIdStore import qualified Wire.ScimUserTimesStore as ScimUserTimesStore -- | Tests for @\/scim\/v2\/Users@. diff --git a/services/spar/test-integration/Util/Core.hs b/services/spar/test-integration/Util/Core.hs index b172bac74b1..4fe1e23e92f 100644 --- a/services/spar/test-integration/Util/Core.hs +++ b/services/spar/test-integration/Util/Core.hs @@ -186,7 +186,6 @@ import qualified Spar.Intra.RpcApp as Intra import Spar.Options import Spar.Run import qualified Spar.Sem.SAMLUserStore as SAMLUserStore -import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore import qualified System.Logger.Extended as Log import System.Random (randomRIO) import Test.Hspec hiding (it, pending, pendingWith, xit) @@ -218,6 +217,7 @@ import Wire.API.User.IdentityProvider import Wire.API.User.Scim import Wire.BrigAPIAccess (getAccount) import qualified Wire.IdPConfigStore as IdPConfigStore +import qualified Wire.ScimExternalIdStore as ScimExternalIdStore -- | Call 'mkEnv' with options from config files. mkEnvFromOptions :: IO TestEnv diff --git a/services/spar/test/Arbitrary.hs b/services/spar/test/Arbitrary.hs index 65a02e8bb07..82089277c58 100644 --- a/services/spar/test/Arbitrary.hs +++ b/services/spar/test/Arbitrary.hs @@ -33,7 +33,6 @@ import SAML2.WebSSO.Test.Arbitrary () import SAML2.WebSSO.Types import Servant.API.ContentTypes import Spar.Scim -import Spar.Scim.Types (ScimUserCreationStatus) import Test.QuickCheck import URI.ByteString import Wire.API.User.IdentityProvider diff --git a/services/spar/test/Test/Spar/Scim/UserSpec.hs b/services/spar/test/Test/Spar/Scim/UserSpec.hs index b8625312528..fb733fb7253 100644 --- a/services/spar/test/Test/Spar/Scim/UserSpec.hs +++ b/services/spar/test/Test/Spar/Scim/UserSpec.hs @@ -28,8 +28,6 @@ import Polysemy.TinyLog import Spar.Scim.User (deleteScimUser) import Spar.Sem.SAMLUserStore import Spar.Sem.SAMLUserStore.Mem (samlUserStoreToMem) -import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore -import Spar.Sem.ScimExternalIdStore.Mem (scimExternalIdStoreToMem) import System.Logger (Msg) import Test.Hspec import Test.QuickCheck @@ -40,6 +38,8 @@ import Wire.BrigAPIAccess import Wire.IdPConfigStore import Wire.IdPConfigStore.Mem (idPToMem) import Wire.IdPConfigStore.Orphans () +import qualified Wire.ScimExternalIdStore as ScimExternalIdStore +import Wire.ScimExternalIdStore.Mem (scimExternalIdStoreToMem) import Wire.ScimUserTimesStore import Wire.ScimUserTimesStore.Mem (scimUserTimesStoreToMem) import Wire.Sem.Logger.TinyLog (discardTinyLogs) diff --git a/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs b/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs index ec978251ea2..413ee4a3246 100644 --- a/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs +++ b/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs @@ -22,10 +22,10 @@ module Test.Spar.Sem.ScimExternalIdStoreSpec where import Arbitrary () import Imports import Polysemy -import Spar.Sem.ScimExternalIdStore.Mem import Spar.Sem.ScimExternalIdStore.Spec import Test.Hspec import Test.Hspec.QuickCheck +import Wire.ScimExternalIdStore.Mem spec :: Spec spec = modifyMaxSuccess (const 1000) $ do From 28f7530950988dabbdc9a5c6dcb6220dd821f9f0 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 29 Jul 2026 19:50:05 +0200 Subject: [PATCH 045/113] Revert "WPB-23631: Move `Spar.Sem.ScimExternalIdStore` in `Wire.ScimExternalIdStore`" This reverts commit b1381a35ada91e64b15ded6670f31777dcf8ebd5. --- changelog.d/5-internal/WPB-23631-6 | 1 - libs/wire-api/src/Wire/API/User/Scim.hs | 8 +------ libs/wire-subsystems/wire-subsystems.cabal | 3 --- services/spar/spar.cabal | 3 +++ services/spar/src/Spar/API.hs | 2 +- services/spar/src/Spar/App.hs | 4 ++-- .../spar/src/Spar/CanonicalInterpreter.hs | 4 ++-- services/spar/src/Spar/Data/Instances.hs | 13 +++++++++++ services/spar/src/Spar/Scim.hs | 2 +- services/spar/src/Spar/Scim/Types.hs | 8 +++++++ services/spar/src/Spar/Scim/User.hs | 6 ++--- .../spar/src/Spar/Sem}/ScimExternalIdStore.hs | 3 ++- .../Sem}/ScimExternalIdStore/Cassandra.hs | 23 ++++--------------- .../src/Spar/Sem}/ScimExternalIdStore/Mem.hs | 9 ++++---- .../src/Spar/Sem/ScimExternalIdStore/Spec.hs | 4 ++-- .../Test/Spar/Scim/UserSpec.hs | 2 +- services/spar/test-integration/Util/Core.hs | 2 +- services/spar/test/Arbitrary.hs | 1 + services/spar/test/Test/Spar/Scim/UserSpec.hs | 4 ++-- .../Test/Spar/Sem/ScimExternalIdStoreSpec.hs | 2 +- 20 files changed, 54 insertions(+), 50 deletions(-) delete mode 100644 changelog.d/5-internal/WPB-23631-6 rename {libs/wire-subsystems/src/Wire => services/spar/src/Spar/Sem}/ScimExternalIdStore.hs (97%) rename {libs/wire-subsystems/src/Wire => services/spar/src/Spar/Sem}/ScimExternalIdStore/Cassandra.hs (85%) rename {libs/wire-subsystems/src/Wire => services/spar/src/Spar/Sem}/ScimExternalIdStore/Mem.hs (88%) diff --git a/changelog.d/5-internal/WPB-23631-6 b/changelog.d/5-internal/WPB-23631-6 deleted file mode 100644 index 527fc5d0fab..00000000000 --- a/changelog.d/5-internal/WPB-23631-6 +++ /dev/null @@ -1 +0,0 @@ -Move `Spar.Sem.ScimExternalIdStore` in `Wire.ScimExternalIdStore`. diff --git a/libs/wire-api/src/Wire/API/User/Scim.hs b/libs/wire-api/src/Wire/API/User/Scim.hs index cda70e95803..8ca092eed10 100644 --- a/libs/wire-api/src/Wire/API/User/Scim.hs +++ b/libs/wire-api/src/Wire/API/User/Scim.hs @@ -70,7 +70,7 @@ import Imports import SAML2.WebSSO qualified as SAML import SAML2.WebSSO.Test.Arbitrary () import Servant.API (FromHttpApiData (..), ToHttpApiData (..)) -import Test.QuickCheck (Gen, elements) +import Test.QuickCheck (Gen) import Test.QuickCheck qualified as QC import Web.HttpApiData (parseHeaderWithPrefix) import Web.Scim.AttrName (AttrName (..)) @@ -515,9 +515,3 @@ newtype ScimTokenName = ScimTokenName {fromScimTokenName :: Text} instance ToSchema ScimTokenName where schema = object $ ScimTokenName <$> fromScimTokenName .= field "name" schema - -data ScimUserCreationStatus = ScimUserCreating | ScimUserCreated - deriving (Eq, Show, Generic) - -instance Arbitrary ScimUserCreationStatus where - arbitrary = elements [ScimUserCreating, ScimUserCreated] diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index f60f6b2af76..fe5eb82af98 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -426,9 +426,6 @@ library Wire.SAMLEmailSubsystem.Interpreter Wire.SamlProtocolSettings Wire.SamlProtocolSettings.Servant - Wire.ScimExternalIdStore - Wire.ScimExternalIdStore.Cassandra - Wire.ScimExternalIdStore.Mem Wire.ScimSubsystem Wire.ScimSubsystem.Error Wire.ScimSubsystem.Interpreter diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal index f177270ca7d..5b0482f74c9 100644 --- a/services/spar/spar.cabal +++ b/services/spar/spar.cabal @@ -76,6 +76,9 @@ library Spar.Sem.SAMLUserStore Spar.Sem.SAMLUserStore.Cassandra Spar.Sem.SAMLUserStore.Mem + Spar.Sem.ScimExternalIdStore + Spar.Sem.ScimExternalIdStore.Cassandra + Spar.Sem.ScimExternalIdStore.Mem Spar.Sem.ScimExternalIdStore.Spec Spar.Sem.ScimTokenStore Spar.Sem.ScimTokenStore.Cassandra diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index b5a86098e6c..de2749a85e1 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -102,6 +102,7 @@ import Spar.Sem.SAML2 (SAML2) import qualified Spar.Sem.SAML2 as SAML2 import Spar.Sem.SAMLUserStore (SAMLUserStore) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore +import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import Spar.Sem.ScimTokenStore (ScimTokenStore) import qualified Spar.Sem.ScimTokenStore as ScimTokenStore import Spar.Sem.VerdictFormatStore (VerdictFormatStore) @@ -133,7 +134,6 @@ import qualified Wire.IdPSubsystem as IdPSubsystem import Wire.Reporter (Reporter) import Wire.SamlProtocolSettings (SamlProtocolSettings) import qualified Wire.SamlProtocolSettings as SamlProtocolSettings -import Wire.ScimExternalIdStore (ScimExternalIdStore) import Wire.ScimSubsystem import Wire.ScimUserTimesStore (ScimUserTimesStore) import qualified Wire.ScimUserTimesStore as ScimUserTimesStore diff --git a/services/spar/src/Spar/App.hs b/services/spar/src/Spar/App.hs index 5949eef21cb..b80baf1c0ac 100644 --- a/services/spar/src/Spar/App.hs +++ b/services/spar/src/Spar/App.hs @@ -77,6 +77,8 @@ import Spar.Orphans () import Spar.Sem.AReqIDStore (AReqIDStore) import Spar.Sem.SAMLUserStore (SAMLUserStore) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore +import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) +import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore import Spar.Sem.ScimTokenStore (ScimTokenStore) import qualified Spar.Sem.ScimTokenStore as ScimTokenStore import Spar.Sem.VerdictFormatStore (VerdictFormatStore) @@ -98,8 +100,6 @@ import Wire.IdPConfigStore (IdPConfigStore) import qualified Wire.IdPConfigStore as IdPConfigStore import Wire.Reporter (Reporter) import qualified Wire.Reporter as Reporter -import Wire.ScimExternalIdStore (ScimExternalIdStore) -import qualified Wire.ScimExternalIdStore as ScimExternalIdStore import Wire.ScimSubsystem.Interpreter import Wire.Sem.Logger (Logger) import qualified Wire.Sem.Logger as Logger diff --git a/services/spar/src/Spar/CanonicalInterpreter.hs b/services/spar/src/Spar/CanonicalInterpreter.hs index b2a4405b850..6b5e5fec4c6 100644 --- a/services/spar/src/Spar/CanonicalInterpreter.hs +++ b/services/spar/src/Spar/CanonicalInterpreter.hs @@ -45,6 +45,8 @@ import Spar.Sem.SAML2 (SAML2) import Spar.Sem.SAML2.Library (saml2ToSaml2WebSso) import Spar.Sem.SAMLUserStore (SAMLUserStore) import Spar.Sem.SAMLUserStore.Cassandra (samlUserStoreToCassandra) +import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) +import Spar.Sem.ScimExternalIdStore.Cassandra (scimExternalIdStoreToCassandra) import Spar.Sem.ScimTokenStore (ScimTokenStore) import Spar.Sem.ScimTokenStore.Cassandra (scimTokenStoreToCassandra) import Spar.Sem.Utils @@ -73,8 +75,6 @@ import Wire.Rpc (Rpc, runRpcWithHttp) import Wire.RpcException import Wire.SamlProtocolSettings (SamlProtocolSettings) import Wire.SamlProtocolSettings.Servant (sparRouteToServant) -import Wire.ScimExternalIdStore (ScimExternalIdStore) -import Wire.ScimExternalIdStore.Cassandra (scimExternalIdStoreToCassandra) import Wire.ScimSubsystem import Wire.ScimSubsystem.Interpreter import Wire.ScimUserTimesStore (ScimUserTimesStore) diff --git a/services/spar/src/Spar/Data/Instances.hs b/services/spar/src/Spar/Data/Instances.hs index 2aec0f7602d..1bd89c0c377 100644 --- a/services/spar/src/Spar/Data/Instances.hs +++ b/services/spar/src/Spar/Data/Instances.hs @@ -38,6 +38,7 @@ import Data.Functor.Alt (Alt (())) import qualified Data.Text.Encoding as T import Data.Text.Encoding.Error import Imports +import Spar.Scim.Types (ScimUserCreationStatus (..)) import URI.ByteString import Wire.API.User.Auth import Wire.API.User.Saml @@ -89,3 +90,15 @@ instance Cql ScimTokenLookupKey where (ScimTokenLookupKeyHashed <$> fromCql s) (ScimTokenLookupKeyPlaintext <$> fromCql s) fromCql _ = Left "ScimTokenLookupKey: expected CqlText" + +instance Cql ScimUserCreationStatus where + ctype = Tagged IntColumn + + toCql ScimUserCreated = CqlInt 0 + toCql ScimUserCreating = CqlInt 1 + + fromCql (CqlInt i) = case i of + 0 -> pure ScimUserCreated + 1 -> pure ScimUserCreating + n -> Left $ "unexpected ScimUserCreationStatus: " ++ show n + fromCql _ = Left "int expected" diff --git a/services/spar/src/Spar/Scim.hs b/services/spar/src/Spar/Scim.hs index 02e6b5a60c6..e67dfa231d2 100644 --- a/services/spar/src/Spar/Scim.hs +++ b/services/spar/src/Spar/Scim.hs @@ -84,6 +84,7 @@ import Spar.Scim.Auth import Spar.Scim.Group () import Spar.Scim.User import Spar.Sem.SAMLUserStore (SAMLUserStore) +import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import Spar.Sem.ScimTokenStore (ScimTokenStore) import System.Logger (Msg) import qualified Web.Scim.Capabilities.MetaSchema as Scim.Meta @@ -99,7 +100,6 @@ import Wire.BrigAPIAccess (BrigAPIAccess) import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.IdPConfigStore (IdPConfigStore) import Wire.Reporter (Reporter) -import Wire.ScimExternalIdStore (ScimExternalIdStore) import Wire.ScimSubsystem import Wire.ScimUserTimesStore (ScimUserTimesStore) import Wire.Sem.Logger (Logger) diff --git a/services/spar/src/Spar/Scim/Types.hs b/services/spar/src/Spar/Scim/Types.hs index abda3fb9a81..b2b6b360af7 100644 --- a/services/spar/src/Spar/Scim/Types.hs +++ b/services/spar/src/Spar/Scim/Types.hs @@ -32,6 +32,8 @@ module Spar.Scim.Types where import Control.Lens (view) import Imports +import Test.QuickCheck (Arbitrary (..)) +import Test.QuickCheck.Gen (elements) import qualified Web.Scim.Schema.Common as Scim import qualified Web.Scim.Schema.User as Scim.User import Wire.API.User (AccountStatus (..)) @@ -87,3 +89,9 @@ normalizeLikeStored usr = tweakActive :: Maybe Scim.ScimBool -> Maybe Scim.ScimBool tweakActive = Just . Scim.ScimBool . maybe True Scim.unScimBool + +data ScimUserCreationStatus = ScimUserCreating | ScimUserCreated + deriving (Eq, Show, Generic) + +instance Arbitrary ScimUserCreationStatus where + arbitrary = elements [ScimUserCreating, ScimUserCreated] diff --git a/services/spar/src/Spar/Scim/User.hs b/services/spar/src/Spar/Scim/User.hs index a8b54e2de02..b6ebc5dbab1 100644 --- a/services/spar/src/Spar/Scim/User.hs +++ b/services/spar/src/Spar/Scim/User.hs @@ -81,6 +81,8 @@ import Spar.Scim.Types import qualified Spar.Scim.Types as ST import Spar.Sem.SAMLUserStore (SAMLUserStore) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore +import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) +import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore import qualified System.Logger.Class as Log import System.Logger.Message (Msg) import qualified URI.ByteString as URIBS @@ -102,7 +104,7 @@ import Wire.API.Team.Role import Wire.API.User import Wire.API.User.IdentityProvider (IdP) import qualified Wire.API.User.RichInfo as RI -import Wire.API.User.Scim (ScimTokenInfo (..), ScimUserCreationStatus (..), ValidScimId (..)) +import Wire.API.User.Scim (ScimTokenInfo (..), ValidScimId (..)) import qualified Wire.API.User.Scim as ST import Wire.BrigAPIAccess (BrigAPIAccess) import qualified Wire.BrigAPIAccess as BrigAPIAccess @@ -110,8 +112,6 @@ import Wire.GalleyAPIAccess (GalleyAPIAccess) import qualified Wire.GalleyAPIAccess as GalleyAPIAccess import Wire.IdPConfigStore (IdPConfigStore) import qualified Wire.IdPConfigStore as IdPConfigStore -import Wire.ScimExternalIdStore (ScimExternalIdStore) -import qualified Wire.ScimExternalIdStore as ScimExternalIdStore import Wire.ScimUserTimesStore (ScimUserTimesStore) import qualified Wire.ScimUserTimesStore as ScimUserTimesStore import Wire.Sem.Logger (Logger) diff --git a/libs/wire-subsystems/src/Wire/ScimExternalIdStore.hs b/services/spar/src/Spar/Sem/ScimExternalIdStore.hs similarity index 97% rename from libs/wire-subsystems/src/Wire/ScimExternalIdStore.hs rename to services/spar/src/Spar/Sem/ScimExternalIdStore.hs index f88533358a4..c4bb2b54ed6 100644 --- a/libs/wire-subsystems/src/Wire/ScimExternalIdStore.hs +++ b/services/spar/src/Spar/Sem/ScimExternalIdStore.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.ScimExternalIdStore +module Spar.Sem.ScimExternalIdStore ( ScimExternalIdStore (..), insert, lookup, @@ -32,6 +32,7 @@ import Data.Text import Imports (Maybe, Show) import Polysemy import Polysemy.Check (deriveGenericK) +import Spar.Scim.Types import Wire.API.User.Scim data ScimExternalIdStore m a where diff --git a/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Cassandra.hs b/services/spar/src/Spar/Sem/ScimExternalIdStore/Cassandra.hs similarity index 85% rename from libs/wire-subsystems/src/Wire/ScimExternalIdStore/Cassandra.hs rename to services/spar/src/Spar/Sem/ScimExternalIdStore/Cassandra.hs index 573a1df51bd..42d098dfe33 100644 --- a/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Cassandra.hs +++ b/services/spar/src/Spar/Sem/ScimExternalIdStore/Cassandra.hs @@ -1,4 +1,3 @@ -{-# OPTIONS_GHC -Wno-orphans #-} {-# OPTIONS_GHC -Wno-redundant-constraints #-} -- This file is part of the Wire Server implementation. @@ -18,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.ScimExternalIdStore.Cassandra +module Spar.Sem.ScimExternalIdStore.Cassandra ( scimExternalIdStoreToCassandra, ) where @@ -28,22 +27,10 @@ import Data.Bifunctor (second) import Data.Id import Imports import Polysemy -import Wire.API.User.Scim (ScimUserCreationStatus (..), ValidScimId (..)) -import Wire.ScimExternalIdStore (ScimExternalIdStore (..)) - --- Moved from Spar.Data.Instances: this is the only consumer of the --- @scim_external.creation_status@ column, so the Cql instance lives here. -instance Cql ScimUserCreationStatus where - ctype = Tagged IntColumn - - toCql ScimUserCreated = CqlInt 0 - toCql ScimUserCreating = CqlInt 1 - - fromCql (CqlInt i) = case i of - 0 -> pure ScimUserCreated - 1 -> pure ScimUserCreating - n -> Left $ "unexpected ScimUserCreationStatus: " ++ show n - fromCql _ = Left "int expected" +import Spar.Data.Instances () +import Spar.Scim.Types (ScimUserCreationStatus (ScimUserCreated)) +import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore (..)) +import Wire.API.User.Scim (ValidScimId (..)) scimExternalIdStoreToCassandra :: forall m r a. diff --git a/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Mem.hs b/services/spar/src/Spar/Sem/ScimExternalIdStore/Mem.hs similarity index 88% rename from libs/wire-subsystems/src/Wire/ScimExternalIdStore/Mem.hs rename to services/spar/src/Spar/Sem/ScimExternalIdStore/Mem.hs index 7f35b69d7f3..5ab14ccd4af 100644 --- a/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Mem.hs +++ b/services/spar/src/Spar/Sem/ScimExternalIdStore/Mem.hs @@ -17,18 +17,19 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.ScimExternalIdStore.Mem +module Spar.Sem.ScimExternalIdStore.Mem ( scimExternalIdStoreToMem, ) where import Data.Id (TeamId, UserId) -import Data.Map qualified as M +import qualified Data.Map as M import Imports import Polysemy import Polysemy.State -import Wire.API.User.Scim (ScimUserCreationStatus, ValidScimId (..)) -import Wire.ScimExternalIdStore +import Spar.Scim.Types (ScimUserCreationStatus) +import Spar.Sem.ScimExternalIdStore +import Wire.API.User.Scim (ValidScimId (..)) scimExternalIdStoreToMem :: Sem (ScimExternalIdStore ': r) a -> diff --git a/services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs b/services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs index d702e10ab62..eab1ba7d47f 100644 --- a/services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs +++ b/services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs @@ -24,11 +24,11 @@ import Data.Id import Imports import Polysemy import Polysemy.Check +import Spar.Scim.Types (ScimUserCreationStatus) +import qualified Spar.Sem.ScimExternalIdStore as E import Test.Hspec import Test.Hspec.QuickCheck import Test.QuickCheck -import Wire.API.User.Scim (ScimUserCreationStatus) -import qualified Wire.ScimExternalIdStore as E propsForInterpreter :: (PropConstraints r f) => diff --git a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs index 545f1ff8977..1ea57c883b8 100644 --- a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs +++ b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs @@ -69,6 +69,7 @@ import Spar.Scim import Spar.Scim.Types (normalizeLikeStored) import qualified Spar.Scim.User as SU import qualified Spar.Sem.SAMLUserStore as SAMLUserStore +import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore import Test.Tasty.HUnit ((@?=)) import qualified Text.XML.DSig as SAML import Util @@ -94,7 +95,6 @@ import Wire.API.User.RichInfo import qualified Wire.API.User.Scim as Spar.Types import qualified Wire.API.User.Search as Search import qualified Wire.BrigAPIAccess as BrigAPIAccess -import qualified Wire.ScimExternalIdStore as ScimExternalIdStore import qualified Wire.ScimUserTimesStore as ScimUserTimesStore -- | Tests for @\/scim\/v2\/Users@. diff --git a/services/spar/test-integration/Util/Core.hs b/services/spar/test-integration/Util/Core.hs index 4fe1e23e92f..b172bac74b1 100644 --- a/services/spar/test-integration/Util/Core.hs +++ b/services/spar/test-integration/Util/Core.hs @@ -186,6 +186,7 @@ import qualified Spar.Intra.RpcApp as Intra import Spar.Options import Spar.Run import qualified Spar.Sem.SAMLUserStore as SAMLUserStore +import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore import qualified System.Logger.Extended as Log import System.Random (randomRIO) import Test.Hspec hiding (it, pending, pendingWith, xit) @@ -217,7 +218,6 @@ import Wire.API.User.IdentityProvider import Wire.API.User.Scim import Wire.BrigAPIAccess (getAccount) import qualified Wire.IdPConfigStore as IdPConfigStore -import qualified Wire.ScimExternalIdStore as ScimExternalIdStore -- | Call 'mkEnv' with options from config files. mkEnvFromOptions :: IO TestEnv diff --git a/services/spar/test/Arbitrary.hs b/services/spar/test/Arbitrary.hs index 82089277c58..65a02e8bb07 100644 --- a/services/spar/test/Arbitrary.hs +++ b/services/spar/test/Arbitrary.hs @@ -33,6 +33,7 @@ import SAML2.WebSSO.Test.Arbitrary () import SAML2.WebSSO.Types import Servant.API.ContentTypes import Spar.Scim +import Spar.Scim.Types (ScimUserCreationStatus) import Test.QuickCheck import URI.ByteString import Wire.API.User.IdentityProvider diff --git a/services/spar/test/Test/Spar/Scim/UserSpec.hs b/services/spar/test/Test/Spar/Scim/UserSpec.hs index fb733fb7253..b8625312528 100644 --- a/services/spar/test/Test/Spar/Scim/UserSpec.hs +++ b/services/spar/test/Test/Spar/Scim/UserSpec.hs @@ -28,6 +28,8 @@ import Polysemy.TinyLog import Spar.Scim.User (deleteScimUser) import Spar.Sem.SAMLUserStore import Spar.Sem.SAMLUserStore.Mem (samlUserStoreToMem) +import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore +import Spar.Sem.ScimExternalIdStore.Mem (scimExternalIdStoreToMem) import System.Logger (Msg) import Test.Hspec import Test.QuickCheck @@ -38,8 +40,6 @@ import Wire.BrigAPIAccess import Wire.IdPConfigStore import Wire.IdPConfigStore.Mem (idPToMem) import Wire.IdPConfigStore.Orphans () -import qualified Wire.ScimExternalIdStore as ScimExternalIdStore -import Wire.ScimExternalIdStore.Mem (scimExternalIdStoreToMem) import Wire.ScimUserTimesStore import Wire.ScimUserTimesStore.Mem (scimUserTimesStoreToMem) import Wire.Sem.Logger.TinyLog (discardTinyLogs) diff --git a/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs b/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs index 413ee4a3246..ec978251ea2 100644 --- a/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs +++ b/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs @@ -22,10 +22,10 @@ module Test.Spar.Sem.ScimExternalIdStoreSpec where import Arbitrary () import Imports import Polysemy +import Spar.Sem.ScimExternalIdStore.Mem import Spar.Sem.ScimExternalIdStore.Spec import Test.Hspec import Test.Hspec.QuickCheck -import Wire.ScimExternalIdStore.Mem spec :: Spec spec = modifyMaxSuccess (const 1000) $ do From 59cb012eb8db368933511a94945db290fc81d422 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Thu, 30 Jul 2026 08:50:05 +0200 Subject: [PATCH 046/113] [WPB-25579] If apps are re-enabled in the team, DO NOT re-activate any apps in the team. (#5347) --- ...m_-do-not-re-activate-any-apps-in-the-team | 1 + integration/test/Test/FeatureFlags/Apps.hs | 9 +++--- .../galley/src/Galley/API/Teams/Features.hs | 32 +++++++++---------- 3 files changed, 21 insertions(+), 21 deletions(-) create mode 100644 changelog.d/3-bug-fixes/WPB-25579-if-apps-are-re-enabled-in-the-team_-do-not-re-activate-any-apps-in-the-team diff --git a/changelog.d/3-bug-fixes/WPB-25579-if-apps-are-re-enabled-in-the-team_-do-not-re-activate-any-apps-in-the-team b/changelog.d/3-bug-fixes/WPB-25579-if-apps-are-re-enabled-in-the-team_-do-not-re-activate-any-apps-in-the-team new file mode 100644 index 00000000000..048128dcb28 --- /dev/null +++ b/changelog.d/3-bug-fixes/WPB-25579-if-apps-are-re-enabled-in-the-team_-do-not-re-activate-any-apps-in-the-team @@ -0,0 +1 @@ +If apps are re-enabled in the team, DO NOT re-activate any apps in the team. diff --git a/integration/test/Test/FeatureFlags/Apps.hs b/integration/test/Test/FeatureFlags/Apps.hs index b96543d142d..7c7afafea71 100644 --- a/integration/test/Test/FeatureFlags/Apps.hs +++ b/integration/test/Test/FeatureFlags/Apps.hs @@ -43,8 +43,9 @@ testAppsInternal = do testPatchApps :: (HasCallStack) => App () testPatchApps = checkPatch OwnDomain "apps" disabled --- | Disabling the apps feature for a team suspends all app users in that team. --- Re-enabling it restores them to active. Regular team members are unaffected. +-- | Disabling the apps feature for a team suspends all app users in +-- that team. Re-enabling it does NOT restore them to active, since +-- they may have been suspended for other reasons earlier. testAppsSuspendOnDisable :: (HasCallStack) => App () testAppsSuspendOnDisable = do (owner, tid, [regularMember]) <- createTeam OwnDomain 2 @@ -84,9 +85,9 @@ testAppsSuspendOnDisable = do resp.status `shouldMatchInt` 200 resp.json %. "status" `shouldMatch` "active" - -- Re-enable the apps feature: app users should be active again + -- Re-enable the apps feature: app users must NOT be re-activated setFeature InternalAPI owner tid "apps" enabled >>= assertSuccess BrigI.getAccountStatus app `bindResponse` \resp -> do resp.status `shouldMatchInt` 200 - resp.json %. "status" `shouldMatch` "active" + resp.json %. "status" `shouldMatch` "suspended" diff --git a/services/galley/src/Galley/API/Teams/Features.hs b/services/galley/src/Galley/API/Teams/Features.hs index 48816c4733f..84d863fa91d 100644 --- a/services/galley/src/Galley/API/Teams/Features.hs +++ b/services/galley/src/Galley/API/Teams/Features.hs @@ -529,23 +529,21 @@ instance SetFeatureConfig AppsConfig where SetFeatureForTeamConstraints AppsConfig (r :: EffectRow) = (Member BrigAPIAccess r) - prepareFeature tid feat = do - let newStatus = case feat.status of - FeatureStatusEnabled -> Active - FeatureStatusDisabled -> Suspended - appIds <- getAppIdsForTeam tid - -- NB: this will work as long as the only reason for suspending - -- apps is "payment plan expired", but should we ever introduce a - -- suspend button for team admins to let them temporarily disable - -- apps without deinstalling them, then we need to keep track of - -- the suspend reason and filter for the right one here. - -- - -- NB(2): this is not terribly efficient, but it's a rarely called - -- operation with usually small numbers of apps. tweak - -- opportunities: (a) only call this loop if enablement actually - -- changes; (b) do the loop over all appIds in postgres with one - -- query. - for_ appIds $ \uid -> setAccountStatus uid newStatus + prepareFeature tid feat = case feat.status of + -- WPB-25579: re-enabling the feature must NOT re-activate apps. An app may + -- have been suspended for reasons other than this feature being disabled + -- (e.g. suspended individually), so we never blanket-reactivate here. + FeatureStatusEnabled -> + -- Do nothing. Some apps may have been suspended for reasons + -- unrelated to the feature flag flipping. + pure () + FeatureStatusDisabled -> do + appIds <- getAppIdsForTeam tid + -- NB: this is not terribly efficient, but it's a rarely called operation + -- with usually small numbers of apps. tweak opportunities: (a) only call + -- this loop if enablement actually changes; (b) loop over all + -- appIds in postgres with one query. + for_ appIds $ \uid -> setAccountStatus uid Suspended instance SetFeatureConfig SimplifiedUserConnectionRequestQRCodeConfig From 1ddcd186496438695d66b0bfa681ab908e45d6fc Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 30 Jul 2026 09:53:33 +0200 Subject: [PATCH 047/113] WPB-27017 [fix] member-update event target user (#5390) --- changelog.d/2-features/WPB-27017 | 2 +- integration/test/Test/AdminlessGroups.hs | 30 +++++++++++++++++++ .../src/Wire/ConversationSubsystem/Update.hs | 29 +++++++++--------- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/changelog.d/2-features/WPB-27017 b/changelog.d/2-features/WPB-27017 index fdf3d808f61..cfb5944d2d5 100644 --- a/changelog.d/2-features/WPB-27017 +++ b/changelog.d/2-features/WPB-27017 @@ -1 +1 @@ -Add adminless-group reconciliation, teardown, and system events for member updates, reminders, and deletion. +Add adminless-group reconciliation, teardown, and system events for member updates, reminders, and deletion. (#5357, #5390) diff --git a/integration/test/Test/AdminlessGroups.hs b/integration/test/Test/AdminlessGroups.hs index c6ba1667978..9870ce6c12f 100644 --- a/integration/test/Test/AdminlessGroups.hs +++ b/integration/test/Test/AdminlessGroups.hs @@ -215,6 +215,36 @@ testAdminlessSetupSystemMemberUpdate = do resp.status `shouldMatchInt` 200 resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin" +testAdminlessSetupMemberUpdateAfterAdminLeaves :: (HasCallStack) => App () +testAdminlessSetupMemberUpdateAfterAdminLeaves = do + (alice, tid, [bob]) <- createTeam OwnDomain 2 + + setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked" + patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "disabled"]) >>= assertSuccess + + conv <- + postConversation + alice + (defProteus {team = Just tid, qualifiedUsers = [bob], newUsersRole = "wire_member"}) + >>= getJSON 201 + + -- Alice leaves while the feature is disabled. Enabling the feature through + -- the public endpoint then reconciles the now-adminless conversation + bindResponse (removeMember alice conv alice) $ \resp -> do + resp.status `shouldMatchInt` 200 + + withWebSockets [bob] $ \[wsBob] -> do + setTeamFeatureConfigVersioned (ExplicitVersion 17) alice tid "preventAdminlessGroups" (mkAdminlessFeature "enabled" "10s" []) >>= assertSuccess + + notif <- awaitMatchFor 20 isMemberUpdateNotif wsBob + notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + notif %. "payload.0.data.qualified_target" `shouldMatch` objQidObject bob + notif %. "payload.0.data.conversation_role" `shouldMatch` "wire_admin" + + bindResponse (getConversation bob conv) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin" + testAdminlessJobsCancelledOnFeatureDisable :: (HasCallStack) => App () testAdminlessJobsCancelledOnFeatureDisable = do (alice, tid, _) <- createTeam OwnDomain 1 diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs index a5a7c673ef0..4500f5f81e0 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs @@ -1328,21 +1328,20 @@ adminlessTryAutopromote mlusr lcnv altAction = do update = OtherMemberUpdate (Just roleNameWireAdmin) for_ autopromotionCandidates $ \candidate -> do E.setOtherMember lcnv candidate update - case mlusr of - Just lusr -> - void $ - sendConversationActionNotifications - (sing @'ConversationMemberUpdateTag) - (tUntagged lusr) - False - Nothing - (qualifyAs lcnv conv) - (convBotsAndMembers conv) - (ConversationMemberUpdate (tUntagged lusr) update) - def - Nothing -> do - now <- Now.get - for_ autopromotionCandidates $ \candidate -> + case mlusr of + Just lusr -> + void $ + sendConversationActionNotifications + (sing @'ConversationMemberUpdateTag) + (tUntagged lusr) + False + Nothing + (qualifyAs lcnv conv) + (convBotsAndMembers conv) + (ConversationMemberUpdate candidate update) + def + Nothing -> do + now <- Now.get Notify.pushSystemEvent Nothing ( SystemEvent From f1e29fc5403491894baa564e7ac55153fe31366e Mon Sep 17 00:00:00 2001 From: Valentin Date: Thu, 30 Jul 2026 11:08:05 +0200 Subject: [PATCH 048/113] fix: change csp override behavior (#5385) --- charts/wire-ingress/templates/_helpers.tpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/wire-ingress/templates/_helpers.tpl b/charts/wire-ingress/templates/_helpers.tpl index 5586e57d02a..f780f7f35c5 100644 --- a/charts/wire-ingress/templates/_helpers.tpl +++ b/charts/wire-ingress/templates/_helpers.tpl @@ -120,7 +120,7 @@ primary (bool), csp (bool). {{- if $tls.secretName -}}{{- $secretName = $tls.secretName -}} {{- else if $primary -}}{{- $secretName = include "wire-ingress.certificateSecretName" $root -}} {{- else -}}{{- $secretName = printf "%s-%s-tls-certificate" $fullname $name -}}{{- end -}} - {{- $cspFlag := true -}} + {{- $cspFlag := not $primary -}} {{- if hasKey $domain "renderCSP" -}}{{- $cspFlag = $domain.renderCSP -}}{{- end -}} {{/* Additional domains cannot share the single wildcard secret created by @@ -148,7 +148,7 @@ primary (bool), csp (bool). "issuerName" ($issuer.name | default $root.Values.tls.issuer.name) "issuerKind" ($issuer.kind | default $root.Values.tls.issuer.kind) "primary" $primary - "csp" (and (not $primary) $cspFlag) -}} + "csp" $cspFlag -}} {{- $out = append $out $entry -}} {{- end -}} {{- else -}} From 1f7a68bb0a7d1703c2ac4ff769dc864ae382c7d8 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Thu, 30 Jul 2026 12:00:45 +0200 Subject: [PATCH 049/113] UserStore,brig: Remove unused code to get rich infos in bulk (#5384) --- changelog.d/5-internal/remove-bulk-get-rich-info | 1 + .../src/Wire/API/Routes/Internal/Brig.hs | 7 ------- libs/wire-subsystems/src/Wire/BrigAPIAccess.hs | 1 - .../src/Wire/BrigAPIAccess/Rpc.hs | 16 ---------------- libs/wire-subsystems/src/Wire/UserStore.hs | 1 - .../src/Wire/UserStore/Cassandra.hs | 10 ---------- .../src/Wire/UserStore/Postgres.hs | 10 ---------- .../test/unit/Wire/MockInterpreters/UserStore.hs | 1 - services/brig/src/Brig/API/Internal.hs | 5 ----- 9 files changed, 1 insertion(+), 51 deletions(-) create mode 100644 changelog.d/5-internal/remove-bulk-get-rich-info diff --git a/changelog.d/5-internal/remove-bulk-get-rich-info b/changelog.d/5-internal/remove-bulk-get-rich-info new file mode 100644 index 00000000000..bf2b726f6c2 --- /dev/null +++ b/changelog.d/5-internal/remove-bulk-get-rich-info @@ -0,0 +1 @@ +brig: Remove /i/users/rich-info \ No newline at end of file diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs index 0bf24d834c0..7ef9eead11a 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs @@ -579,13 +579,6 @@ type AccountAPI = :> "rich-info" :> Get '[Servant.JSON] RichInfo ) - :<|> Named - "iGetRichInfoMulti" - ( "users" - :> "rich-info" - :> QueryParam' '[Optional, Strict] "ids" (CommaSeparatedList UserId) - :> Get '[Servant.JSON] GetRichInfoMultiResponse - ) :<|> Named "iHeadHandle" ( CanThrow 'InvalidHandle diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs index 972df7782cf..c23b679ed72 100644 --- a/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs @@ -84,7 +84,6 @@ data BrigAPIAccess m a where GetUsers :: [UserId] -> BrigAPIAccess m [User] DeleteUser :: UserId -> BrigAPIAccess m () GetContactList :: UserId -> BrigAPIAccess m [UserId] - GetRichInfoMultiUser :: [UserId] -> BrigAPIAccess m [(UserId, RichInfo)] GetSize :: TeamId -> BrigAPIAccess m TeamSize LookupClients :: [UserId] -> BrigAPIAccess m UserClients LookupClientsFull :: [UserId] -> BrigAPIAccess m UserClientsFull diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs index 0c754c43f5a..e42a4791392 100644 --- a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs @@ -100,8 +100,6 @@ interpretBrigAccess brigEndpoint = deleteUser uid GetContactList uid -> do getContactList uid - GetRichInfoMultiUser uids -> do - getRichInfoMultiUser uids GetUserExportData uid -> do getUserExportData uid GetSize tid -> do @@ -372,20 +370,6 @@ getContactList uid = do . expect2xx cUsers <$> decodeBodyOrThrow "brig" r --- | Calls 'Brig.API.Internal.getRichInfoMultiH' -getRichInfoMultiUser :: - (Member Rpc r, Member (Input Endpoint) r, Member (Error ParseException) r) => - [UserId] -> - Sem r [(UserId, RichInfo)] -getRichInfoMultiUser = chunkify $ \uids -> do - resp <- - brigRequest $ - method GET - . paths ["/i/users/rich-info"] - . queryItem "ids" (toByteString' (List uids)) - . expect2xx - decodeBodyOrThrow "brig" resp - -- | Calls 'Brig.API.Internal.getUserExportDataH' getUserExportData :: (Member Rpc r, Member (Input Endpoint) r, Member (Error ParseException) r) => diff --git a/libs/wire-subsystems/src/Wire/UserStore.hs b/libs/wire-subsystems/src/Wire/UserStore.hs index 8545d4c563d..46bc9e2ff57 100644 --- a/libs/wire-subsystems/src/Wire/UserStore.hs +++ b/libs/wire-subsystems/src/Wire/UserStore.hs @@ -105,7 +105,6 @@ data UserStore m a where -- GetUsersTeams :: [UserId] -> UserStore m (Maybe [TeamId]) UpdateUserTeam :: UserId -> TeamId -> UserStore m () GetRichInfo :: UserId -> UserStore m (Maybe RichInfoAssocList) - LookupRichInfos :: [UserId] -> UserStore m [(UserId, RichInfo)] UpdateRichInfo :: UserId -> RichInfoAssocList -> UserStore m () UpsertHashedPassword :: UserId -> Password -> UserStore m () LookupHashedPassword :: UserId -> UserStore m (Maybe Password) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs index bc919cd854d..a08179e4e77 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs @@ -70,7 +70,6 @@ interpretUserStoreCassandra casClient = GetUserTeam uid -> getUserTeamImpl uid UpdateUserTeam uid tid -> updateUserTeamImpl uid tid GetRichInfo uid -> getRichInfoImpl uid - LookupRichInfos uids -> lookupRichInfosImpl uids UpsertHashedPassword uid pw -> upsertHashedPasswordImpl uid pw LookupHashedPassword uid -> lookupHashedPasswordImpl uid GetUserAuthenticationInfo uid -> getUserAuthenticationInfoImpl uid @@ -256,15 +255,6 @@ lookupNameImpl u = nameSelect :: PrepQuery R (Identity UserId) (Identity Name) nameSelect = "SELECT name FROM user WHERE id = ?" --- | Returned rich infos are in the same order as users -lookupRichInfosImpl :: (MonadClient m) => [UserId] -> m [(UserId, RichInfo)] -lookupRichInfosImpl users = do - mapMaybe (\(uid, mbRi) -> (uid,) . RichInfo <$> mbRi) - <$> retry x1 (query richInfoSelectMulti (params LocalQuorum (Identity users))) - where - richInfoSelectMulti :: PrepQuery R (Identity [UserId]) (UserId, Maybe RichInfoAssocList) - richInfoSelectMulti = "SELECT user, json FROM rich_info WHERE user in ?" - lookupFeatureConferenceCallingImpl :: (MonadClient m) => UserId -> m (Maybe FeatureStatus) lookupFeatureConferenceCallingImpl uid = do let q = query1 select (params LocalQuorum (Identity uid)) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 9192317b407..4b47b76820d 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -68,7 +68,6 @@ interpretUserStorePostgres = GetUserTeam uid -> getUserTeamImpl uid UpdateUserTeam uid tid -> updateUserTeamImpl uid tid GetRichInfo uid -> getRichInfoImpl uid - LookupRichInfos uids -> lookupRichInfosImpl uids UpsertHashedPassword uid pw -> upsertHashedPasswordImpl uid pw LookupHashedPassword uid -> lookupHashedPasswordImpl uid GetUserAuthenticationInfo uid -> getUserAuthenticationInfoImpl uid @@ -691,15 +690,6 @@ updateRichInfoImpl uid richInfo = dimapPG [resultlessStatement|UPDATE wire_user SET rich_info = $2 :: jsonb WHERE id = $1 :: uuid|] -lookupRichInfosImpl :: (PGConstraints r) => [UserId] -> Sem r [(UserId, RichInfo)] -lookupRichInfosImpl uids = - mapMaybe (\(uid, mbRi) -> (uid,) . RichInfo <$> mbRi) <$> runStatement uids select - where - select :: Hasql.Statement [UserId] [(UserId, Maybe RichInfoAssocList)] - select = - dimapPG @(Vector _) - [vectorStatement|SELECT id :: uuid, rich_info :: json? FROM wire_user WHERE id = ANY($1 :: uuid[])|] - upsertHashedPasswordImpl :: (PGConstraints r) => UserId -> Password -> Sem r () upsertHashedPasswordImpl uid pw = runStatement (uid, pw) upsert where diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs index 9f6d24d9ef1..8cedb9a6584 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs @@ -139,7 +139,6 @@ inMemoryUserStoreInterpreter = interpret $ \case map (\u -> if u.id == uid then u {teamId = Just tid} :: StoredUser else u) GetRichInfo _ -> error "GetRichInfo: not implemented" - LookupRichInfos _ -> error "LookupRichInfos: not implemented" UpdateRichInfo {} -> error "UpdateRichInfo: Not implemented" UpsertHashedPassword uid pw -> modify $ Map.insert uid pw diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index ae535479e9a..5959ce6be2b 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -291,7 +291,6 @@ accountAPI = :<|> Named @"iPutHandle" updateHandleH :<|> Named @"iPutUserName" updateUserNameH :<|> Named @"iGetRichInfo" getRichInfoH - :<|> Named @"iGetRichInfoMulti" getRichInfoMultiH :<|> Named @"iHeadHandle" checkHandleInternalH :<|> Named @"iConnectionUpdate" updateConnectionInternalH :<|> Named @"iListClients" internalListClientsH @@ -966,10 +965,6 @@ getRichInfoH uid = RichInfo . fromMaybe mempty <$> lift (liftSem $ UserStore.getRichInfo uid) -getRichInfoMultiH :: (Member UserStore r) => Maybe (CommaSeparatedList UserId) -> Handler r BrigIRoutes.GetRichInfoMultiResponse -getRichInfoMultiH (maybe [] fromCommaSeparatedList -> uids) = - lift $ liftSem $ BrigIRoutes.GetRichInfoMultiResponse <$> UserStore.lookupRichInfos uids - updateHandleH :: (Member UserSubsystem r) => UserId -> From 26f5de6f2e97adec99112c0bbfbe1cf7176e7bf6 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Thu, 30 Jul 2026 15:08:47 +0200 Subject: [PATCH 050/113] integration: Add request id to every request (#5386) The request id contains name of the test, method, path and request number --- integration/test/Testlib/Env.hs | 2 ++ integration/test/Testlib/HTTP.hs | 13 ++++++++++++- integration/test/Testlib/Types.hs | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/integration/test/Testlib/Env.hs b/integration/test/Testlib/Env.hs index 1c76c47eb6b..c242d453245 100644 --- a/integration/test/Testlib/Env.hs +++ b/integration/test/Testlib/Env.hs @@ -171,11 +171,13 @@ mkEnv currentTestName ge = do pks <- newIORef (zip [1 ..] somePrekeys) lpks <- newIORef someLastPrekeys curlTrace <- newIORef [] + reqId <- newIORef 0 pure Env { serviceMap = gServiceMap ge, domain1 = gDomain1 ge, domain2 = gDomain2 ge, + requestIdCounter = reqId, integrationTestHostName = gIntegrationTestHostName ge, federationV0Domain = gFederationV0Domain ge, federationV1Domain = gFederationV1Domain ge, diff --git a/integration/test/Testlib/HTTP.hs b/integration/test/Testlib/HTTP.hs index 643ff624f12..ad48ed444d4 100644 --- a/integration/test/Testlib/HTTP.hs +++ b/integration/test/Testlib/HTTP.hs @@ -231,9 +231,20 @@ zType = addHeader "Z-Type" zHost :: String -> HTTP.Request -> HTTP.Request zHost = addHeader "Z-Host" +newRequestId :: App Int +newRequestId = do + counter <- asks (.requestIdCounter) + liftIO . atomicModifyIORef counter $ \x -> (x + 1, x + 1) + submit :: String -> HTTP.Request -> App Response submit method req0 = do - let request = req0 {HTTP.method = T.encodeUtf8 (T.pack method)} + reqIdNum <- newRequestId + testName <- asks (fromMaybe "not_test" . (.currentTestName)) + let reqId = testName <> "__" <> method <> "_" <> cs (HTTP.path req0) <> "__" <> show reqIdNum + let request = + (req0 & addHeader "Request-Id" reqId) + { HTTP.method = T.encodeUtf8 (T.pack method) + } manager <- asks (.manager) response <- liftIO $ HTTP.httpLbs request manager let json = Aeson.decode (HTTP.responseBody response) diff --git a/integration/test/Testlib/Types.hs b/integration/test/Testlib/Types.hs index 5b9de9696f4..9917f10ec58 100644 --- a/integration/test/Testlib/Types.hs +++ b/integration/test/Testlib/Types.hs @@ -252,6 +252,7 @@ stopQueueWatcher watcher = void $ tryPutMVar watcher.doneVar () -- | Initialised once per test. data Env = Env { serviceMap :: Map String ServiceMap, + requestIdCounter :: IORef Int, domain1 :: String, domain2 :: String, integrationTestHostName :: String, From 582947c99a759d124de1d94a97c0ef0ed1fcb4b5 Mon Sep 17 00:00:00 2001 From: Leif Battermann Date: Thu, 30 Jul 2026 15:31:53 +0200 Subject: [PATCH 051/113] WPB-27393 guard bulk conversation member replacement against adminless groups (#5387) --- changelog.d/1-api-changes/WPB-27393 | 1 + integration/test/Test/AdminlessGroups.hs | 80 ++++ libs/wire-api/src/Wire/API/Routes/Features.hs | 6 + .../API/Routes/Public/Galley/Conversation.hs | 35 +- .../Wire/API/Routes/Public/Galley/Feature.hs | 2 +- .../src/Wire/ConversationSubsystem.hs | 1 + .../Wire/ConversationSubsystem/Interpreter.hs | 4 +- .../src/Wire/ConversationSubsystem/Update.hs | 190 +++++--- .../ConversationSubsystem/InterpreterSpec.hs | 407 +++++++++++++++++- .../test/unit/Wire/MockInterpreters.hs | 1 + .../MockInterpreters/ConversationStore.hs | 37 ++ libs/wire-subsystems/wire-subsystems.cabal | 1 + .../src/Galley/API/Public/Conversation.hs | 3 +- 13 files changed, 691 insertions(+), 77 deletions(-) create mode 100644 changelog.d/1-api-changes/WPB-27393 create mode 100644 libs/wire-subsystems/test/unit/Wire/MockInterpreters/ConversationStore.hs diff --git a/changelog.d/1-api-changes/WPB-27393 b/changelog.d/1-api-changes/WPB-27393 new file mode 100644 index 00000000000..7deb183d310 --- /dev/null +++ b/changelog.d/1-api-changes/WPB-27393 @@ -0,0 +1 @@ +V17 `PUT /conversations/{domain}/{conversation}/members` rejects replacements that would leave a regular group without an admin; V16 retains the legacy autopromotion behavior. diff --git a/integration/test/Test/AdminlessGroups.hs b/integration/test/Test/AdminlessGroups.hs index 9870ce6c12f..05ef119f487 100644 --- a/integration/test/Test/AdminlessGroups.hs +++ b/integration/test/Test/AdminlessGroups.hs @@ -186,6 +186,86 @@ testAdminlessSetupOnFeatureEnable = do bindResponse (GalleyI.getConversation conv) $ \resp -> do resp.status `shouldMatchInt` 404 +testAdminlessReplaceMembers :: (HasCallStack) => App () +testAdminlessReplaceMembers = do + testVersion 16 $ \alice bob conv version -> do + bobId <- bob %. "qualified_id" + -- V16 retains the legacy behavior and autopromotes the remaining eligible member. + bindResponse (replaceMembers alice conv def {users = [bobId], version = Just version}) $ \resp -> do + resp.status `shouldMatchInt` 200 + + bindResponse (getConversation bob conv) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin" + members <- resp.json %. "members.others" & asList + shouldBeEmpty members + + testVersion 17 $ \alice bob conv version -> do + bobId <- bob %. "qualified_id" + -- V17 rejects a replacement that would remove the last admin while leaving + -- only eligible non-admin members. + bindResponse (replaceMembers alice conv def {users = [bobId], version = Just version}) $ \resp -> do + resp.status `shouldMatchInt` 403 + resp.json %. "label" `shouldMatch` "adminless-conversation" + eligibleMembers <- resp.json %. "eligible_members" & asList + eligibleMembers `shouldMatchSet` [bobId] + + -- The rejected replacement does not mutate membership: Bob is still a + -- member and Alice is still the admin. + bindResponse (getConversation alice conv) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin" + members <- resp.json %. "members.others" & asList + memberIds <- traverse (%. "qualified_id") members + memberIds `shouldMatchSet` [bobId] + where + testVersion version assertResult = do + (alice, tid, [bob]) <- createTeam OwnDomain 2 + configureAdminlessGroupsFeature OwnDomain tid "enabled" "10s" [] + conv <- postConversation alice (defProteus {team = Just tid, qualifiedUsers = [bob], newUsersRole = "wire_member"}) >>= getJSON 201 + assertResult alice bob conv version + +testAdminlessReplaceMembersAddsAdmin :: (HasCallStack) => App () +testAdminlessReplaceMembersAddsAdmin = do + (alice, tid, [bob, charlie]) <- createTeam OwnDomain 3 + configureAdminlessGroupsFeature OwnDomain tid "enabled" "10s" [] + conv <- postConversation alice (defProteus {team = Just tid, qualifiedUsers = [bob], newUsersRole = "wire_member"}) >>= getJSON 201 + bobId <- bob %. "qualified_id" + charlieId <- charlie %. "qualified_id" + + -- V17 accepts replacing the existing admin when the same request adds a new + -- admin, because the resulting conversation is not adminless. + bindResponse (replaceMembers alice conv def {users = [bobId, charlieId], role = Just "wire_admin", version = Just 17}) $ \resp -> do + resp.status `shouldMatchInt` 200 + + bindResponse (getConversation charlie conv) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin" + members <- resp.json %. "members.others" & asList + memberIds <- traverse (%. "qualified_id") members + memberIds `shouldMatchSet` [bobId] + +testAdminlessReplaceMembersAddsEligibleMember :: (HasCallStack) => App () +testAdminlessReplaceMembersAddsEligibleMember = do + (alice, tid, [bob]) <- createTeam OwnDomain 2 + configureAdminlessGroupsFeature OwnDomain tid "enabled" "10s" [] + conv <- postConversation alice (defProteus {team = Just tid, qualifiedUsers = [], newUsersRole = "wire_member"}) >>= getJSON 201 + bobId <- bob %. "qualified_id" + + -- V17 rejects a replacement that removes the only admin even when the + -- eligible member is added by the same request. + bindResponse + (replaceMembers alice conv def {users = [bobId], role = Just "wire_member", version = Just 17}) + $ \resp -> do + resp.status `shouldMatchInt` 403 + resp.json %. "label" `shouldMatch` "adminless-conversation" + eligibleMembers <- resp.json %. "eligible_members" & asList + eligibleMembers `shouldMatchSet` [bobId] + + bindResponse (getConversation alice conv) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin" + testAdminlessSetupSystemMemberUpdate :: (HasCallStack) => App () testAdminlessSetupSystemMemberUpdate = do (alice, tid, [bob]) <- createTeam OwnDomain 2 diff --git a/libs/wire-api/src/Wire/API/Routes/Features.hs b/libs/wire-api/src/Wire/API/Routes/Features.hs index 5759e37659e..3b1401d26b0 100644 --- a/libs/wire-api/src/Wire/API/Routes/Features.hs +++ b/libs/wire-api/src/Wire/API/Routes/Features.hs @@ -19,6 +19,7 @@ module Wire.API.Routes.Features where import Wire.API.Conversation.Role import Wire.API.Error.Galley +import Wire.API.Routes.Version (Version (V17)) import Wire.API.Team.Feature type family FeatureErrors cfg where @@ -39,3 +40,8 @@ type family FeatureAPIDesc cfg where FeatureAPIDesc RequireExternalEmailVerificationConfig = "

Controls whether externally managed email addresses (from SAML or SCIM) must be verified by the user, or are auto-activated.

The external feature name is kept as validateSAMLemails for backward compatibility. That name is misleading because the feature also applies to SCIM-managed users, and it controls email ownership verification rather than generic email validation.

" FeatureAPIDesc _ = "" + +type family VersionedFeatureAPIDesc v cfg where + VersionedFeatureAPIDesc V17 PreventAdminlessGroupsConfig = + "

For API version 17, use duration strings for the timeout fields. The request body must have the following shape:

{\n  "config": {\n    "deletionTimeoutDuration": "7d",\n    "promotionStrategy": "alphabetical",\n    "reminderTimeoutDurations": [\n      "2d",\n      "4d",\n      "6d"\n    ]\n  },\n  "status": "enabled"\n}

Older API versions use the legacy numeric fields deletionTimeout and reminderTimeouts.

" + VersionedFeatureAPIDesc _ cfg = FeatureAPIDesc cfg diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs index 9117d6c58c6..a353a2b630b 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs @@ -955,7 +955,7 @@ type ConversationAPI = -- - MemberJoin event for added members -- - MemberLeave event for removed members :<|> Named - "replace-members-in-conversation" + "replace-members-in-conversation@v16" ( Summary "Replace the members of a conversation." :> Description "This will add any members not already in the conversation, \ @@ -964,9 +964,42 @@ type ConversationAPI = \The roles of already existing members will not be changed \ \even if these members are included in the request body and their role differs from the role provided in this request." :> From 'V13 + :> Until 'V17 + :> CanThrow ('ActionDenied 'AddConversationMember) + :> CanThrow ('ActionDenied 'RemoveConversationMember) + :> CanThrow ('ActionDenied 'LeaveConversation) + :> CanThrow 'ConvNotFound + :> CanThrow 'InvalidOperation + :> CanThrow 'TooManyMembers + :> CanThrow 'ConvAccessDenied + :> CanThrow 'NotATeamMember + :> CanThrow 'NotConnected + :> CanThrow 'MissingLegalholdConsent + :> CanThrow 'GroupIdVersionNotSupported + :> CanThrow NonFederatingBackends + :> CanThrow UnreachableBackends + :> ZLocalUser + :> ZConn + :> "conversations" + :> QualifiedCapture "cnv" ConvId + :> "members" + :> ReqBody '[Servant.JSON] InviteQualified + :> MultiVerb1 'PUT '[JSON] (RespondEmpty 200 "Conversation members replaced") + ) + :<|> Named + "replace-members-in-conversation" + ( Summary "Replace the members of a conversation." + :> Description + "This will add any members not already in the conversation, \ + \and remove any members not in the provided list except users that are associated via a user group. \ + \The given role in the request body will be applied to all added members. \ + \The roles of already existing members will not be changed \ + \even if these members are included in the request body and their role differs from the role provided in this request." + :> From 'V17 :> CanThrow ('ActionDenied 'AddConversationMember) :> CanThrow ('ActionDenied 'RemoveConversationMember) :> CanThrow ('ActionDenied 'LeaveConversation) + :> CanThrow AdminlessConversation :> CanThrow 'ConvNotFound :> CanThrow 'InvalidOperation :> CanThrow 'TooManyMembers diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs index 0f7d9511a1e..30033a50f9b 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs @@ -91,7 +91,7 @@ type FeatureAPI = type VersionedFeatureAPIPut named reqBodyVersion cfg = Named named - ( Description (FeatureAPIDesc cfg) + ( Description (VersionedFeatureAPIDesc reqBodyVersion cfg) :> ZUser :> Summary (AppendSymbol "Put config for " (FeatureSymbol cfg)) :> CanThrow OperationDenied diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs index b9b24d5274e..56b01784b8f 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs @@ -482,6 +482,7 @@ data ConversationSubsystem m a where InviteQualified -> ConversationSubsystem m (UpdateResult Event) ReplaceMembers :: + RemoveMemberResponseMode -> Local UserId -> ConnId -> Qualified ConvId -> diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs index 69603274250..b8036f25bbd 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs @@ -269,8 +269,8 @@ interpretConversationSubsystem = interpret $ \case mapErrors $ Update.addQualifiedMembersUnqualified lusr con cnv invite AddMembers lusr zcon qcnv invite -> mapErrors $ Update.addMembers lusr zcon qcnv invite - ReplaceMembers lusr zcon qcnv invite -> - mapErrors $ Update.replaceMembers lusr zcon qcnv invite + ReplaceMembers responseMode lusr zcon qcnv invite -> + mapErrors $ Update.replaceMembers responseMode lusr zcon qcnv invite JoinConversationById lusr con cnv -> mapErrors $ Update.joinConversationById lusr con cnv JoinConversationByReusableCode lusr con req -> diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs index 4500f5f81e0..cc840b17839 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs @@ -54,6 +54,8 @@ module Wire.ConversationSubsystem.Update updateOtherMember, eligibleAdminFallbackMembers, isLeavingLastConversationAdmin, + guardPreventAdminlessGroups, + guardPreventAdminlessGroupsFor, removeMemberQualified, deleteUserFromTeamConversationsImpl, removeMemberFromLocalConv, @@ -981,12 +983,13 @@ replaceMembers :: Member JobSubsystem r, Member (Input ConversationSubsystemConfig) r ) => + RemoveMemberResponseMode -> Local UserId -> ConnId -> Qualified ConvId -> InviteQualified -> Sem r () -replaceMembers lusr zcon qcnv (InviteQualified invitedUsers role) = do +replaceMembers responseMode lusr zcon qcnv (InviteQualified invitedUsers role) = do lcnv <- ensureLocal lusr qcnv conv <- getConversationWithError lcnv @@ -1000,14 +1003,23 @@ replaceMembers lusr zcon qcnv (InviteQualified invitedUsers role) = do permissionCheck JoinRegularConversations . Just ugs <- getUserGroupsForConv conv.id_ - -- Get current members (excluding the requesting user) - let currentMembers = Set.fromList $ map (\m -> Qualified m.id_ (tDomain lcnv)) (toList conv.localMembers) + -- Removals apply only to local members. Additions must account for remote + -- members too, because re-inviting an existing remote member does not change + -- their role and must not be treated as adding an admin. + let currentLocalMembers = Set.fromList $ map (\m -> Qualified m.id_ (tDomain lcnv)) (toList conv.localMembers) + currentRemoteMembers = Set.fromList $ map (tUntagged . (.id_)) conv.remoteMembers invitedMembersSet = Set.fromList $ toList invitedUsers ugMembers = concatMap (fmap (flip Qualified (tDomain lusr)) . V.toList . runIdentity . (.members)) (V.toList ugs) -- the invited users plus all user group members should stay allUsersThatShouldStay = Set.fromList $ toList $ appendList invitedUsers ugMembers - toRemove = Set.difference currentMembers allUsersThatShouldStay - toAdd = Set.difference invitedMembersSet currentMembers + toRemove = Set.difference currentLocalMembers allUsersThatShouldStay + toAdd = Set.difference invitedMembersSet (currentLocalMembers <> currentRemoteMembers) + addedAdmins = if role == roleNameWireAdmin then toAdd else Set.empty + + -- Apply the same adminless protection as DELETE to the complete removal + -- set. The guard is a preflight so V16+ cannot partially apply a replacement + -- before returning AdminlessConversation. + guardPreventAdminlessGroupsFor responseMode lcnv lusr toRemove addedAdmins toAdd -- If both sets are empty, return Unchanged unless (Set.null toRemove && Set.null toAdd) $ do @@ -1229,13 +1241,55 @@ guardPreventAdminlessGroups :: Local UserId -> Qualified UserId -> Sem r () -guardPreventAdminlessGroups responseMode lcnv lusr victim = do +guardPreventAdminlessGroups responseMode lcnv lusr victim = + guardPreventAdminlessGroupsFor responseMode lcnv lusr (Set.singleton victim) Set.empty Set.empty + +guardPreventAdminlessGroupsFor :: + ( Member ConversationStore r, + Member (Error AdminlessConversation) r, + Member (ErrorS 'ConvNotFound) r, + Member (ErrorS ('ActionDenied 'ModifyOtherConversationMember)) r, + Member (ErrorS 'InvalidOperation) r, + Member (ErrorS 'ConvMemberNotFound) r, + Member (Error FederationError) r, + Member BrigAPIAccess r, + Member Random r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member BackendNotificationQueueAccess r, + Member FeaturesConfigSubsystem r, + Member TeamSubsystem r, + Member JobSubsystem r + ) => + RemoveMemberResponseMode -> + Local ConvId -> + Local UserId -> + Set (Qualified UserId) -> + Set (Qualified UserId) -> + Set (Qualified UserId) -> + Sem r () +guardPreventAdminlessGroupsFor responseMode lcnv lusr victims addedAdmins addedMembers = do conv <- getConversationWithError lcnv when (isAdminlessCheckCandidate conv) $ for_ conv.metadata.cnvmTeam $ \tid -> do (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid - -- we cannot use the onAdminless helper here because this check happens _before_ removing the potential admin - when (feature.status == FeatureStatusEnabled && isLeavingLastConversationAdmin (qUnqualified victim) conv) $ do - eligibleMembers <- eligibleAdminFallbackMembers lcnv (Just (qUnqualified victim)) conv + let victims' = Set.map qUnqualified victims + removingLastAdmin = + Set.null addedAdmins + && any + (\member -> member.convRoleName == roleNameWireAdmin && Set.member member.id_ victims') + conv.localMembers + && not + ( any + (\member -> member.convRoleName == roleNameWireAdmin && Set.notMember member.id_ victims') + conv.localMembers + ) + && not (any (\member -> member.convRoleName == roleNameWireAdmin) conv.remoteMembers) + when (feature.status == FeatureStatusEnabled && removingLastAdmin) $ do + let addedMembersForEligibility = case responseMode of + RemoveMemberLegacyResponse -> Set.empty + RemoveMemberEligibleMembersResponse -> addedMembers + eligibleMembers <- eligibleAdminFallbackMembersFor lcnv victims' addedMembersForEligibility conv case (responseMode, eligibleMembers) of (RemoveMemberLegacyResponse, x : xs) -> do seed <- randomWord64 @@ -1285,24 +1339,6 @@ scheduleDeletion lcnv mlusr tid feature = do timeoutToNominalDiffTime = realToFrac . duration . durationLiteralValue . preventAdminlessTimeoutLiteral -onAdminless :: - ( Member ConversationStore r, - Member (ErrorS 'ConvNotFound) r, - Member BrigAPIAccess r, - Member FeaturesConfigSubsystem r - ) => - Local ConvId -> - (StoredConversation -> LockableFeature PreventAdminlessGroupsConfig -> [(Qualified UserId, User.Name)] -> Sem r ()) -> - Sem r () -onAdminless lcnv action = do - conv <- getConversationWithError lcnv - when (isAdminlessCheckCandidate conv) $ for_ conv.metadata.cnvmTeam $ \tid -> do - (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid - let adminExists = any (\member -> member.convRoleName == roleNameWireAdmin) conv.localMembers || any (\member -> member.convRoleName == roleNameWireAdmin) conv.remoteMembers - when (feature.status == FeatureStatusEnabled && not adminExists) $ do - eligibleMembers <- eligibleAdminFallbackMembers lcnv Nothing conv - action conv feature eligibleMembers - adminlessTryAutopromote :: ( Member ConversationStore r, Member (ErrorS 'ConvNotFound) r, @@ -1320,39 +1356,44 @@ adminlessTryAutopromote :: (StoredConversation -> LockableFeature PreventAdminlessGroupsConfig -> [(Qualified UserId, User.Name)] -> Sem r ()) -> Sem r () adminlessTryAutopromote mlusr lcnv altAction = do - onAdminless lcnv $ \conv feature eligibleMembers -> do - case eligibleMembers of - x : xs -> do - seed <- randomWord64 - let autopromotionCandidates = selectAutopromotionCandidate seed feature.config.promotionStrategy (x :| xs) - update = OtherMemberUpdate (Just roleNameWireAdmin) - for_ autopromotionCandidates $ \candidate -> do - E.setOtherMember lcnv candidate update - case mlusr of - Just lusr -> - void $ - sendConversationActionNotifications - (sing @'ConversationMemberUpdateTag) - (tUntagged lusr) - False - Nothing - (qualifyAs lcnv conv) - (convBotsAndMembers conv) - (ConversationMemberUpdate candidate update) - def - Nothing -> do - now <- Now.get - Notify.pushSystemEvent - Nothing - ( SystemEvent - (tUntagged lcnv) + conv <- getConversationWithError lcnv + when (isAdminlessCheckCandidate conv) $ for_ conv.metadata.cnvmTeam $ \tid -> do + (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid + let adminExists = any (\member -> member.convRoleName == roleNameWireAdmin) conv.localMembers || any (\member -> member.convRoleName == roleNameWireAdmin) conv.remoteMembers + when (feature.status == FeatureStatusEnabled && not adminExists) $ do + eligibleMembers <- eligibleAdminFallbackMembers lcnv Nothing conv + case eligibleMembers of + x : xs -> do + seed <- randomWord64 + let autopromotionCandidates = selectAutopromotionCandidate seed feature.config.promotionStrategy (x :| xs) + update = OtherMemberUpdate (Just roleNameWireAdmin) + for_ autopromotionCandidates $ \candidate -> do + E.setOtherMember lcnv candidate update + case mlusr of + Just lusr -> + void $ + sendConversationActionNotifications + (sing @'ConversationMemberUpdateTag) + (tUntagged lusr) + False Nothing - now - conv.metadata.cnvmTeam - (EdSystemMemberUpdate (memberUpdateData candidate update)) - ) - (Set.fromList (map (.id_) conv.localMembers)) - [] -> altAction conv feature eligibleMembers + (qualifyAs lcnv conv) + (convBotsAndMembers conv) + (ConversationMemberUpdate candidate update) + def + Nothing -> do + now <- Now.get + Notify.pushSystemEvent + Nothing + ( SystemEvent + (tUntagged lcnv) + Nothing + now + conv.metadata.cnvmTeam + (EdSystemMemberUpdate (memberUpdateData candidate update)) + ) + (Set.fromList (map (.id_) conv.localMembers)) + [] -> altAction conv feature eligibleMembers where memberUpdateData candidate memberUpdate' = MemberUpdateData @@ -1473,13 +1514,34 @@ eligibleAdminFallbackMembers :: StoredConversation -> Sem r [(Qualified UserId, User.Name)] eligibleAdminFallbackMembers lcnv mLeavingUser conv = do - users <- Brig.getUsers (map (.id_) (filter ((/= mLeavingUser) . Just . (.id_)) conv.localMembers)) + eligibleAdminFallbackMembersFor lcnv (maybe Set.empty Set.singleton mLeavingUser) Set.empty conv + +eligibleAdminFallbackMembersFor :: + (Member BrigAPIAccess r) => + Local ConvId -> + Set UserId -> + Set (Qualified UserId) -> + StoredConversation -> + Sem r [(Qualified UserId, User.Name)] +eligibleAdminFallbackMembersFor lcnv leavingUsers addedUsers conv = do + let existingCandidates = + [ (Qualified member.id_ (tDomain lcnv), member.id_) + | member <- conv.localMembers, + Set.notMember member.id_ leavingUsers + ] + addedLocalCandidates = + [ (user, qUnqualified user) + | user <- Set.toList addedUsers, + qDomain user == tDomain lcnv + ] + candidates = existingCandidates <> addedLocalCandidates + candidateIds = Set.toList (Set.fromList (map snd candidates)) + users <- Brig.getUsers candidateIds let usersById = Map.fromList [(User.userId u, u) | u <- users] pure - [ (tUntagged (qualifyAs lcnv member.id_), u.userDisplayName) - | member <- conv.localMembers, - Just member.id_ /= mLeavingUser, - Just u <- [Map.lookup member.id_ usersById], + [ (qualifiedId, u.userDisplayName) + | (qualifiedId, candidateId) <- candidates, + Just u <- [Map.lookup candidateId usersById], isEligibleUser u ] where diff --git a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs index 650208e4cca..2affa1d807f 100644 --- a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs @@ -17,41 +17,48 @@ module Wire.ConversationSubsystem.InterpreterSpec (spec) where +import Data.Aeson qualified as A import Data.Default (def) import Data.Domain (Domain (..)) import Data.Id import Data.Map.Strict qualified as Map import Data.Qualified +import Data.Set qualified as Set import Data.Tagged (Tagged) import Data.UUID qualified as UUID import Imports import Polysemy import Polysemy.Error import Polysemy.Input +import Polysemy.State import Test.Hspec import Test.Hspec.QuickCheck (prop) -import Test.QuickCheck (Arbitrary (..), Gen, arbitrary, chooseInt, counterexample, generate, ioProperty, vectorOf, (===)) +import Test.QuickCheck (Arbitrary (..), Gen, Property, arbitrary, chooseInt, conjoin, counterexample, generate, ioProperty, vectorOf, (===)) import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Config (ConversationSubsystemConfig (..)) import Wire.API.Conversation.Protocol (ConversationMLSData (..), Protocol (..)) import Wire.API.Conversation.Role hiding (DeleteConversation) import Wire.API.Error.Galley (AdminlessConversation (..), GalleyError (..)) +import Wire.API.Event.Conversation (Event (..), EventData (..), MemberUpdateData (..)) import Wire.API.Federation.Client (FederatorClient) import Wire.API.Federation.Error (FederationError) -import Wire.API.Team.Feature (AllTeamFeatures, FeatureStatus (..), LockStatus (..), LockableFeature (..), PreventAdminlessGroupsConfig, npProject, npUpdate) -import Wire.API.User (AccountStatus (..), User (..), UserType (..), userId) +import Wire.API.Team.Feature +import Wire.API.User import Wire.BackendNotificationQueueAccess (BackendNotificationQueueAccess (..)) import Wire.BrigAPIAccess (BrigAPIAccess (..)) import Wire.ConversationStore (ConversationStore (..)) import Wire.ConversationSubsystem (RemoveMemberResponseMode (..)) -import Wire.ConversationSubsystem.Update (removeMemberQualified) +import Wire.ConversationSubsystem.Update (guardPreventAdminlessGroupsFor, removeMemberQualified) import Wire.ExternalAccess (ExternalAccess (..)) import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem (..)) import Wire.FederationAPIAccess (FederationAPIAccess (..)) import Wire.JobSubsystem (JobSubsystem (..)) +import Wire.MockInterpreters.ConversationStore (inMemoryConversationStoreInterpreter) +import Wire.MockInterpreters.NotificationSubsystem (inMemoryNotificationSubsystemInterpreter) import Wire.MockInterpreters.Now (defaultTime, interpretNowConst) +import Wire.MockInterpreters.Random (runRandomPure) import Wire.MockInterpreters.TinyLog (noopLogger) -import Wire.NotificationSubsystem (NotificationSubsystem (..)) +import Wire.NotificationSubsystem (NotificationSubsystem (..), Push (..)) import Wire.ProposalStore (ProposalStore (..)) import Wire.Sem.Random (Random (..)) import Wire.StoredConversation @@ -59,6 +66,253 @@ import Wire.TeamSubsystem (TeamSubsystem (..)) spec :: Spec spec = describe "ConversationSubsystem.Interpreter" do + prop "guardPreventAdminlessGroupsFor promotes an eligible member and emits a targeted update [legacy]" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let expectedTarget = head fx.fixtureEligibleMembers + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers fx.fixtureFeatures fx.fixtureConversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ + case result of + Left err -> counterexample ("unexpected adminless error: " <> show err) False + Right _ -> + conjoin + [ updatedTargets === [expectedTarget], + scheduledJobs === [], + assertMemberUpdatePush expectedTarget pushes + ] + + prop "guardPreventAdminlessGroupsFor promotes all eligible members [legacy]" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let features = + npUpdate @PreventAdminlessGroupsConfig + ( LockableFeature + FeatureStatusEnabled + LockStatusUnlocked + ((def :: PreventAdminlessGroupsConfig) {promotionStrategy = PromotionStrategyAll}) + ) + def + expectedTargets = fx.fixtureEligibleMembers + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers features fx.fixtureConversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ + case result of + Left err -> counterexample ("unexpected adminless error: " <> show err) False + Right _ -> + conjoin + [ Set.fromList updatedTargets === Set.fromList expectedTargets, + scheduledJobs === [], + assertMemberUpdatePushes expectedTargets pushes + ] + + prop "guardPreventAdminlessGroupsFor reports eligible members for the V17 response" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let expectedEligible = fx.fixtureEligibleMembers + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers fx.fixtureFeatures fx.fixtureConversations $ + guardPreventAdminlessGroupsFor + RemoveMemberEligibleMembersResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ + case result of + Left err -> + conjoin + [ err === AdminlessConversation {eligibleMembers = expectedEligible}, + updatedTargets === [], + scheduledJobs === [], + length pushes === 0 + ] + Right _ -> counterexample "expected adminless-conversation error" False + + prop "guardPreventAdminlessGroupsFor includes newly added eligible members in the V17 response" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let newlyAdded = head fx.fixtureEligibleMembers + conversations = + Map.adjust + (\conv -> conv {localMembers = [newMemberWithRole (leaving, roleNameWireAdmin)]}) + convId + fx.fixtureConversations + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers fx.fixtureFeatures conversations $ + guardPreventAdminlessGroupsFor + RemoveMemberEligibleMembersResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + (Set.singleton newlyAdded) + pure $ + case result of + Left err -> + conjoin + [ err === AdminlessConversation {eligibleMembers = [newlyAdded]}, + updatedTargets === [], + scheduledJobs === [], + length pushes === 0 + ] + Right _ -> counterexample "expected adminless-conversation error" False + + prop "guardPreventAdminlessGroupsFor schedules deletion when no eligible members exist" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let users = + [ user {userType = UserTypeApp} + | user <- fx.fixtureUsers + ] + features = + npUpdate @PreventAdminlessGroupsConfig + ( LockableFeature + FeatureStatusEnabled + LockStatusUnlocked + ((def :: PreventAdminlessGroupsConfig) {reminderTimeouts = []}) + ) + def + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest users features fx.fixtureConversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ + case result of + Left err -> counterexample ("unexpected adminless error: " <> show err) False + Right _ -> + conjoin + [ updatedTargets === [], + length pushes === 0, + scheduledJobs === [ScheduledAdminlessDeletion] + ] + + prop "guardPreventAdminlessGroupsFor does nothing when the feature is disabled" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let features = + npUpdate @PreventAdminlessGroupsConfig + (LockableFeature FeatureStatusDisabled LockStatusUnlocked def) + def + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers features fx.fixtureConversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ assertNoAdminlessAction pushes updatedTargets scheduledJobs result + + prop "guardPreventAdminlessGroupsFor does nothing when another local admin remains" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let conversations = + Map.adjust + ( \conv -> + conv + { localMembers = + [ newMemberWithRole (leaving, roleNameWireAdmin), + newMemberWithRole (eligible1, roleNameWireAdmin), + newMemberWithRole (eligible2, roleNameWireMember) + ] + } + ) + convId + fx.fixtureConversations + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers fx.fixtureFeatures conversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ assertNoAdminlessAction pushes updatedTargets scheduledJobs result + + prop "guardPreventAdminlessGroupsFor does nothing when a remote admin remains" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let remoteAdmin = + RemoteMember + { id_ = toRemoteUnsafe domain eligible1, + convRoleName = roleNameWireAdmin + } + conversations = + Map.adjust (withRemoteMembers [remoteAdmin]) convId fx.fixtureConversations + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers fx.fixtureFeatures conversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ assertNoAdminlessAction pushes updatedTargets scheduledJobs result + + prop "guardPreventAdminlessGroupsFor does nothing when an admin is being added" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers fx.fixtureFeatures fx.fixtureConversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + (Set.singleton (head fx.fixtureEligibleMembers)) + Set.empty + pure $ assertNoAdminlessAction pushes updatedTargets scheduledJobs result + + prop "guardPreventAdminlessGroupsFor ignores non-regular and channel conversations" $ + \domain teamId convId leaving eligible1 eligible2 outOfScopeChannel -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let conversations = + Map.adjust (withOutOfScopeMetadata outOfScopeChannel) convId fx.fixtureConversations + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers fx.fixtureFeatures conversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ assertNoAdminlessAction pushes updatedTargets scheduledJobs result + prop "removeMemberQualified returns adminless-conversation error" $ \convDomain teamId @@ -98,6 +352,7 @@ spec = describe "ConversationSubsystem.Interpreter" do ] result = run + . runState @[ScheduledAdminlessJob] [] . runError @AdminlessConversation . runError @(Tagged ('ActionDenied 'RemoveConversationMember) ()) . runError @(Tagged ('ActionDenied 'ModifyOtherConversationMember) ()) @@ -128,11 +383,73 @@ spec = describe "ConversationSubsystem.Interpreter" do . noopLogger $ removeMemberQualified RemoveMemberEligibleMembersResponse lusr connId qcnv qvictim pure $ - case result of + case snd result of Left err -> err === AdminlessConversation {eligibleMembers = expectedEligible} Right _ -> counterexample ("expected adminless-conversation, got " <> show result) False + where + runAdminlessGroupsTest users features conversations testCode = + run + . runState @[Push] [] + . runState @[Qualified UserId] [] + . runState @[ScheduledAdminlessJob] [] + . runError @AdminlessConversation + . runError @(Tagged ('ActionDenied 'ModifyOtherConversationMember) ()) + . runError @(Tagged 'ConvMemberNotFound ()) + . runError @(Tagged 'ConvNotFound ()) + . runError @(Tagged 'InvalidOperation ()) + . runError @FederationError + . inMemoryConversationStoreInterpreter conversations + . interpretBrig users + . interpretFeatures features + . interpretBackendNotificationQueueAccess + . interpretFederation + . interpretExternalAccess + . inMemoryNotificationSubsystemInterpreter + . interpretTeamSubsystem + . interpretJobSubsystem + . interpretNowConst defaultTime + . runRandomPure + . noopLogger + $ testCode + +data AdminlessGroupsFixture = AdminlessGroupsFixture + { fixtureUsers :: [User], + fixtureLocalUser :: Local UserId, + fixtureLocalConversation :: Local ConvId, + fixtureVictim :: Qualified UserId, + fixtureEligibleMembers :: [Qualified UserId], + fixtureConversations :: Map.Map ConvId StoredConversation, + fixtureFeatures :: AllTeamFeatures + } + +mkAdminlessGroupsFixture :: Domain -> TeamId -> ConvId -> UserId -> UserId -> UserId -> IO AdminlessGroupsFixture +mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 = do + user1 <- mkUserWithName "Alice" domain UserTypeRegular eligible1 + user2 <- mkUserWithName "Bob" domain UserTypeRegular eligible2 + let conv = + StoredConversation + { id_ = convId, + localMembers = + [ newMemberWithRole (leaving, roleNameWireAdmin), + newMemberWithRole (eligible1, roleNameWireMember), + newMemberWithRole (eligible2, roleNameWireMember) + ], + remoteMembers = [], + metadata = (defConversationMetadata (Just leaving)) {cnvmTeam = Just teamId}, + protocol = ProtocolMLS (ConversationMLSData (GroupId "mock-group-id") Nothing) + } + pure + AdminlessGroupsFixture + { fixtureUsers = [user1, user2], + fixtureLocalUser = toLocalUnsafe domain leaving, + fixtureLocalConversation = toLocalUnsafe domain convId, + fixtureVictim = Qualified leaving domain, + fixtureEligibleMembers = [Qualified eligible1 domain, Qualified eligible2 domain], + fixtureConversations = Map.singleton convId conv, + fixtureFeatures = npUpdate @PreventAdminlessGroupsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) def + } data MemberInputs = MemberInputs { localUserIds :: [UserId], @@ -168,6 +485,59 @@ instance Arbitrary MemberInputs where ] } +assertMemberUpdatePush :: Qualified UserId -> [Push] -> Property +assertMemberUpdatePush expectedTarget = assertMemberUpdatePushes [expectedTarget] + +assertMemberUpdatePushes :: [Qualified UserId] -> [Push] -> Property +assertMemberUpdatePushes expectedTargets pushes = + case traverse decodeMemberUpdate pushes of + Left err -> counterexample err False + Right updates -> + conjoin + [ length pushes === length expectedTargets, + Set.fromList (map (.misTarget) updates) === Set.fromList expectedTargets, + conjoin [update.misConvRoleName === Just roleNameWireAdmin | update <- updates] + ] + where + decodeMemberUpdate push = + case A.fromJSON @(Event) (A.Object push.json) of + A.Success Event {evtData = EdMemberUpdate update} -> Right update + A.Success event -> Left ("unexpected event: " <> show event) + A.Error err -> Left ("failed to decode push: " <> err) + +assertNoAdminlessAction :: + [Push] -> + [Qualified UserId] -> + [ScheduledAdminlessJob] -> + Either AdminlessConversation a -> + Property +assertNoAdminlessAction pushes updatedTargets scheduledJobs result = + case result of + Left err -> counterexample ("unexpected adminless error: " <> show err) False + Right _ -> + conjoin + [ updatedTargets === [], + length pushes === 0, + scheduledJobs === [] + ] + +withRemoteMembers :: [RemoteMember] -> StoredConversation -> StoredConversation +withRemoteMembers members (StoredConversation convId localMembers _ metadata protocol) = + StoredConversation convId localMembers members metadata protocol + +withOutOfScopeMetadata :: Bool -> StoredConversation -> StoredConversation +withOutOfScopeMetadata outOfScopeChannel (StoredConversation convId localMembers remoteMembers metadata protocol) = + StoredConversation + convId + localMembers + remoteMembers + ( metadata + { cnvmType = if outOfScopeChannel then RegularConv else One2OneConv, + cnvmGroupConvType = if outOfScopeChannel then Just Channel else Just GroupConversation + } + ) + protocol + -- Build one lazy infinite pool of distinct IDs and slice it into categories. -- Using position in the stream, rather than per-category prefixes, makes the -- disjointness guarantee obvious in the test data itself. @@ -242,6 +612,8 @@ interpretBackendNotificationQueueAccess :: Sem r a interpretBackendNotificationQueueAccess = interpret $ \case + EnqueueNotificationsConcurrently _ remotes _ -> + if null remotes then pure (Right []) else error "unexpected remote notification in guard test" _ -> error "unexpected BackendNotificationQueueAccess call in test" interpretProposalStore :: @@ -260,9 +632,16 @@ interpretTeamSubsystem :: Sem r a interpretTeamSubsystem = interpret $ \case + InternalGetTeamMember _ _ -> pure Nothing _ -> error "unexpected TeamSubsystem call in test" +data ScheduledAdminlessJob + = ScheduledAdminlessDeletion + | ScheduledAdminlessReminder + deriving stock (Eq, Show) + interpretJobSubsystem :: + (Member (State [ScheduledAdminlessJob]) r) => Sem (JobSubsystem ': r) a -> Sem r a interpretJobSubsystem = @@ -270,9 +649,9 @@ interpretJobSubsystem = ScheduleAdminlessSetupJob {} -> pure () ScheduleAdminlessDeletionJob {} -> - pure () + modify @[ScheduledAdminlessJob] (<> [ScheduledAdminlessDeletion]) ScheduleAdminlessReminderJob {} -> - pure () + modify @[ScheduledAdminlessJob] (<> [ScheduledAdminlessReminder]) CancelAdminlessJobsForTeam {} -> pure () @@ -301,3 +680,15 @@ mkUser domain utype uid = do userStatus = Active, userService = Nothing } + +mkUserWithName :: Text -> Domain -> UserType -> UserId -> IO User +mkUserWithName name domain utype uid = do + base <- generate arbitrary + pure + base + { userQualifiedId = Qualified uid domain, + userType = utype, + userStatus = Active, + userService = Nothing, + userDisplayName = Name name + } diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs index c1bdcdb2628..4630c0c7f77 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs @@ -26,6 +26,7 @@ import Wire.MockInterpreters.AuthenticationSubsystem as MockInterpreters import Wire.MockInterpreters.BackgroundJobPublisher as MockInterpreters import Wire.MockInterpreters.BlockListStore as MockInterpreters import Wire.MockInterpreters.ClientStore as MockInterpreters +import Wire.MockInterpreters.ConversationStore as MockInterpreters import Wire.MockInterpreters.ConversationSubsystem as MockInterpreters import Wire.MockInterpreters.CryptoSign as MockInterpreters import Wire.MockInterpreters.DomainRegistrationStore as MockInterpreters diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ConversationStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ConversationStore.hs new file mode 100644 index 00000000000..166b45c14d7 --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ConversationStore.hs @@ -0,0 +1,37 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it +-- under the terms of the GNU Affero General Public License as published by the +-- Free Software Foundation, either version 3 of the License, or (at your +-- option) any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +module Wire.MockInterpreters.ConversationStore where + +import Data.Id (ConvId, UserId) +import Data.Map qualified as Map +import Data.Qualified (Qualified) +import Imports +import Polysemy +import Polysemy.State +import Wire.ConversationStore (ConversationStore (..)) +import Wire.StoredConversation (StoredConversation) + +inMemoryConversationStoreInterpreter :: + (Member (State [Qualified UserId]) r) => + Map.Map ConvId StoredConversation -> + InterpreterFor ConversationStore r +inMemoryConversationStoreInterpreter store = + interpret $ \case + GetConversation cid -> pure (Map.lookup cid store) + SetOtherMember _ target _ -> modify @[(Qualified UserId)] (<> [target]) + _ -> error "ConversationStore: not implemented in mock" diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index fe5eb82af98..001df802a52 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -628,6 +628,7 @@ test-suite wire-subsystems-tests Wire.MockInterpreters.BackgroundJobPublisher Wire.MockInterpreters.BlockListStore Wire.MockInterpreters.ClientStore + Wire.MockInterpreters.ConversationStore Wire.MockInterpreters.ConversationSubsystem Wire.MockInterpreters.CryptoSign Wire.MockInterpreters.DomainRegistrationStore diff --git a/services/galley/src/Galley/API/Public/Conversation.hs b/services/galley/src/Galley/API/Public/Conversation.hs index 22210867583..ab42b7422c3 100644 --- a/services/galley/src/Galley/API/Public/Conversation.hs +++ b/services/galley/src/Galley/API/Public/Conversation.hs @@ -78,7 +78,8 @@ conversationAPI = <@> mkNamedAPI @"add-members-to-conversation-unqualified" (\lusr con cnv invite -> addMembers lusr con (tUntagged (qualifyAs lusr cnv)) (InviteQualified (fmap (tUntagged . qualifyAs lusr) (invUsers invite)) (invRoleName invite))) <@> mkNamedAPI @"add-members-to-conversation-unqualified2" addQualifiedMembersUnqualified <@> mkNamedAPI @"add-members-to-conversation" addMembers - <@> mkNamedAPI @"replace-members-in-conversation" replaceMembers + <@> mkNamedAPI @"replace-members-in-conversation@v16" (replaceMembers RemoveMemberLegacyResponse) + <@> mkNamedAPI @"replace-members-in-conversation" (replaceMembers RemoveMemberEligibleMembersResponse) <@> mkNamedAPI @"join-conversation-by-id-unqualified" joinConversationById <@> mkNamedAPI @"join-conversation-by-code-unqualified" joinConversationByReusableCode <@> mkNamedAPI @"code-check" checkReusableCode From b412c1cf6241e54f3f28ac47d0b2007957a7dd0d Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 31 Jul 2026 12:12:24 +0200 Subject: [PATCH 052/113] [WPB-18127] Update email templates to v1.0.155. (#5344) --- .../5-internal/email-templates-v1.0.155 | 1 + libs/wire-subsystems/template-version | 2 +- .../team/email/app-availability-change.html | 1 + .../de/team/email/app-availability-change.txt | 24 +++++++++++++++++++ .../templates/de/team/email/app-creation.html | 1 + .../templates/de/team/email/app-creation.txt | 24 +++++++++++++++++++ .../templates/de/team/email/app-deletion.html | 1 + .../templates/de/team/email/app-deletion.txt | 21 ++++++++++++++++ .../de/team/email/app-metadata-change.html | 1 + .../de/team/email/app-metadata-change.txt | 22 +++++++++++++++++ .../de/team/email/app-token-change.html | 1 + .../de/team/email/app-token-change.txt | 21 ++++++++++++++++ .../de/team/email/idp-config-change.html | 2 +- .../de/team/email/idp-config-change.txt | 4 ++-- .../email/app-availability-change-subject.txt | 1 + .../team/email/app-availability-change.html | 1 + .../en/team/email/app-availability-change.txt | 23 ++++++++++++++++++ .../en/team/email/app-creation-subject.txt | 1 + .../templates/en/team/email/app-creation.html | 1 + .../templates/en/team/email/app-creation.txt | 22 +++++++++++++++++ .../en/team/email/app-deletion-subject.txt | 1 + .../templates/en/team/email/app-deletion.html | 1 + .../templates/en/team/email/app-deletion.txt | 20 ++++++++++++++++ .../email/app-metadata-change-subject.txt | 1 + .../en/team/email/app-metadata-change.html | 1 + .../en/team/email/app-metadata-change.txt | 21 ++++++++++++++++ .../team/email/app-token-change-subject.txt | 1 + .../en/team/email/app-token-change.html | 1 + .../en/team/email/app-token-change.txt | 20 ++++++++++++++++ libs/wire-subsystems/templates/version | 2 +- .../mails/idp-config-change_created_de.txt | 4 ++-- .../mails/idp-config-change_deleted_de.txt | 4 ++-- .../mails/idp-config-change_updated_de.txt | 4 ++-- 33 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 changelog.d/5-internal/email-templates-v1.0.155 create mode 100644 libs/wire-subsystems/templates/de/team/email/app-availability-change.html create mode 100644 libs/wire-subsystems/templates/de/team/email/app-availability-change.txt create mode 100644 libs/wire-subsystems/templates/de/team/email/app-creation.html create mode 100644 libs/wire-subsystems/templates/de/team/email/app-creation.txt create mode 100644 libs/wire-subsystems/templates/de/team/email/app-deletion.html create mode 100644 libs/wire-subsystems/templates/de/team/email/app-deletion.txt create mode 100644 libs/wire-subsystems/templates/de/team/email/app-metadata-change.html create mode 100644 libs/wire-subsystems/templates/de/team/email/app-metadata-change.txt create mode 100644 libs/wire-subsystems/templates/de/team/email/app-token-change.html create mode 100644 libs/wire-subsystems/templates/de/team/email/app-token-change.txt create mode 100644 libs/wire-subsystems/templates/en/team/email/app-availability-change-subject.txt create mode 100644 libs/wire-subsystems/templates/en/team/email/app-availability-change.html create mode 100644 libs/wire-subsystems/templates/en/team/email/app-availability-change.txt create mode 100644 libs/wire-subsystems/templates/en/team/email/app-creation-subject.txt create mode 100644 libs/wire-subsystems/templates/en/team/email/app-creation.html create mode 100644 libs/wire-subsystems/templates/en/team/email/app-creation.txt create mode 100644 libs/wire-subsystems/templates/en/team/email/app-deletion-subject.txt create mode 100644 libs/wire-subsystems/templates/en/team/email/app-deletion.html create mode 100644 libs/wire-subsystems/templates/en/team/email/app-deletion.txt create mode 100644 libs/wire-subsystems/templates/en/team/email/app-metadata-change-subject.txt create mode 100644 libs/wire-subsystems/templates/en/team/email/app-metadata-change.html create mode 100644 libs/wire-subsystems/templates/en/team/email/app-metadata-change.txt create mode 100644 libs/wire-subsystems/templates/en/team/email/app-token-change-subject.txt create mode 100644 libs/wire-subsystems/templates/en/team/email/app-token-change.html create mode 100644 libs/wire-subsystems/templates/en/team/email/app-token-change.txt diff --git a/changelog.d/5-internal/email-templates-v1.0.155 b/changelog.d/5-internal/email-templates-v1.0.155 new file mode 100644 index 00000000000..bc4a3f536c5 --- /dev/null +++ b/changelog.d/5-internal/email-templates-v1.0.155 @@ -0,0 +1 @@ +Updated email templates to v1.0.155 \ No newline at end of file diff --git a/libs/wire-subsystems/template-version b/libs/wire-subsystems/template-version index e28f3a1b1bb..a21d12cfba1 100644 --- a/libs/wire-subsystems/template-version +++ b/libs/wire-subsystems/template-version @@ -1 +1 @@ -v1.0.148 +v1.0.155 diff --git a/libs/wire-subsystems/templates/de/team/email/app-availability-change.html b/libs/wire-subsystems/templates/de/team/email/app-availability-change.html new file mode 100644 index 00000000000..2f71397558f --- /dev/null +++ b/libs/wire-subsystems/templates/de/team/email/app-availability-change.html @@ -0,0 +1 @@ +

${brand_label_url}

Änderung der Verfügbarkeit einer App

${actor} hat die Details einer App Ihres Teams geändert.

App-Name: ${app_name}
Datum: ${date}

Vorherige Verfügbarkeit: ${previous_availability}
Neue Verfügbarkeit: ${new_availability}

Team-Name: ${team_name}
Team-ID: ${team_id}

Wenn Sie Fragen haben, dann kontaktieren Sie uns bitte.

                                                           
\ No newline at end of file diff --git a/libs/wire-subsystems/templates/de/team/email/app-availability-change.txt b/libs/wire-subsystems/templates/de/team/email/app-availability-change.txt new file mode 100644 index 00000000000..db6f3ba4a8d --- /dev/null +++ b/libs/wire-subsystems/templates/de/team/email/app-availability-change.txt @@ -0,0 +1,24 @@ +[${brand_logo}] + +${brand_label_url} [${brand_url}] + +ÄNDERUNG DER VERFÜGBARKEIT EINER APP +${actor} hat die Details einer App Ihres Teams geändert. + +App-Name: ${app_name} +Datum: ${date} + +Vorherige Verfügbarkeit: ${previous_availability} +Neue Verfügbarkeit: ${new_availability} + +Team-Name: ${team_name} +Team-ID: ${team_id} + +Team-Management öffnen [${url}]Wenn Sie Fragen haben, dann kontaktieren Sie uns [${support}] bitte. + + +-------------------------------------------------------------------------------- + +Datenschutzrichtlinien und Nutzungsbedingungen [${legal}] · Missbrauch melden +[${misuse}] +${copyright}. ALLE RECHTE VORBEHALTEN. \ No newline at end of file diff --git a/libs/wire-subsystems/templates/de/team/email/app-creation.html b/libs/wire-subsystems/templates/de/team/email/app-creation.html new file mode 100644 index 00000000000..f5fdd0d2c78 --- /dev/null +++ b/libs/wire-subsystems/templates/de/team/email/app-creation.html @@ -0,0 +1 @@ +

${brand_label_url}

Neue App erstellt

${actor} hat eine App in Ihrem Team erstellt.

App-Name: ${app_name}
Datum: ${date}

Team-Name: ${team_name}
Team-ID: ${team_id}

Berechtigungen:
${permissions}

Wenn Sie Fragen haben, dann kontaktieren Sie uns bitte.

                                                           
\ No newline at end of file diff --git a/libs/wire-subsystems/templates/de/team/email/app-creation.txt b/libs/wire-subsystems/templates/de/team/email/app-creation.txt new file mode 100644 index 00000000000..3c07dab94a0 --- /dev/null +++ b/libs/wire-subsystems/templates/de/team/email/app-creation.txt @@ -0,0 +1,24 @@ +[${brand_logo}] + +${brand_label_url} [${brand_url}] + +NEUE APP ERSTELLT +${actor} hat eine App in Ihrem Team erstellt. + +App-Name: ${app_name} +Datum: ${date} + +Team-Name: ${team_name} +Team-ID: ${team_id} + +Berechtigungen: +${permissions} + +Team-Management öffnen [${url}]Wenn Sie Fragen haben, dann kontaktieren Sie uns [${support}] bitte. + + +-------------------------------------------------------------------------------- + +Datenschutzrichtlinien und Nutzungsbedingungen [${legal}] · Missbrauch melden +[${misuse}] +${copyright}. ALLE RECHTE VORBEHALTEN. \ No newline at end of file diff --git a/libs/wire-subsystems/templates/de/team/email/app-deletion.html b/libs/wire-subsystems/templates/de/team/email/app-deletion.html new file mode 100644 index 00000000000..b13af11593b --- /dev/null +++ b/libs/wire-subsystems/templates/de/team/email/app-deletion.html @@ -0,0 +1 @@ +

${brand_label_url}

App gelöscht

${actor} hat eine App Ihres Team gelöscht.

App-Name: ${app_name}
Datum: ${date}

Team-Name: ${team_name}
Team-ID: ${team_id}

Wenn Sie Fragen haben, dann kontaktieren Sie uns bitte.

                                                           
\ No newline at end of file diff --git a/libs/wire-subsystems/templates/de/team/email/app-deletion.txt b/libs/wire-subsystems/templates/de/team/email/app-deletion.txt new file mode 100644 index 00000000000..9899778691c --- /dev/null +++ b/libs/wire-subsystems/templates/de/team/email/app-deletion.txt @@ -0,0 +1,21 @@ +[${brand_logo}] + +${brand_label_url} [${brand_url}] + +APP GELÖSCHT +${actor} hat eine App Ihres Team gelöscht. + +App-Name: ${app_name} +Datum: ${date} + +Team-Name: ${team_name} +Team-ID: ${team_id} + +Team-Management öffnen [${url}]Wenn Sie Fragen haben, dann kontaktieren Sie uns [${support}] bitte. + + +-------------------------------------------------------------------------------- + +Datenschutzrichtlinien und Nutzungsbedingungen [${legal}] · Missbrauch melden +[${misuse}] +${copyright}. ALLE RECHTE VORBEHALTEN. \ No newline at end of file diff --git a/libs/wire-subsystems/templates/de/team/email/app-metadata-change.html b/libs/wire-subsystems/templates/de/team/email/app-metadata-change.html new file mode 100644 index 00000000000..88001e31e98 --- /dev/null +++ b/libs/wire-subsystems/templates/de/team/email/app-metadata-change.html @@ -0,0 +1 @@ +

${brand_label_url}

Details für eine App geändert

${actor} hat die Details einer App Ihres Teams geändert.

Neuer Name: ${new_app_name}
Vorheriger Name: ${previous_app_name}
Datum: ${date}

Team-Name: ${team_name}
Team-ID: ${team_id}

Wenn Sie Fragen haben, dann kontaktieren Sie uns bitte.

                                                           
\ No newline at end of file diff --git a/libs/wire-subsystems/templates/de/team/email/app-metadata-change.txt b/libs/wire-subsystems/templates/de/team/email/app-metadata-change.txt new file mode 100644 index 00000000000..f1339f2440c --- /dev/null +++ b/libs/wire-subsystems/templates/de/team/email/app-metadata-change.txt @@ -0,0 +1,22 @@ +[${brand_logo}] + +${brand_label_url} [${brand_url}] + +DETAILS FÜR EINE APP GEÄNDERT +${actor} hat die Details einer App Ihres Teams geändert. + +Neuer Name: ${new_app_name} +Vorheriger Name: ${previous_app_name} +Datum: ${date} + +Team-Name: ${team_name} +Team-ID: ${team_id} + +Team-Management öffnen [${url}]Wenn Sie Fragen haben, dann kontaktieren Sie uns [${support}] bitte. + + +-------------------------------------------------------------------------------- + +Datenschutzrichtlinien und Nutzungsbedingungen [${legal}] · Missbrauch melden +[${misuse}] +${copyright}. ALLE RECHTE VORBEHALTEN. \ No newline at end of file diff --git a/libs/wire-subsystems/templates/de/team/email/app-token-change.html b/libs/wire-subsystems/templates/de/team/email/app-token-change.html new file mode 100644 index 00000000000..9c8f5115bad --- /dev/null +++ b/libs/wire-subsystems/templates/de/team/email/app-token-change.html @@ -0,0 +1 @@ +

${brand_label_url}

Aktualisierter App-Authentifizierungstoken

${actor} hat den Authentifizierungstoken einer App Ihres Teams aktualisiert.

App-Name: ${app_name}
Datum: ${date}

Team-Name: ${team_name}
Team-ID: ${team_id}

Wenn Sie Fragen haben, dann kontaktieren Sie uns bitte.

                                                           
\ No newline at end of file diff --git a/libs/wire-subsystems/templates/de/team/email/app-token-change.txt b/libs/wire-subsystems/templates/de/team/email/app-token-change.txt new file mode 100644 index 00000000000..59bb4ba7d95 --- /dev/null +++ b/libs/wire-subsystems/templates/de/team/email/app-token-change.txt @@ -0,0 +1,21 @@ +[${brand_logo}] + +${brand_label_url} [${brand_url}] + +AKTUALISIERTER APP-AUTHENTIFIZIERUNGSTOKEN +${actor} hat den Authentifizierungstoken einer App Ihres Teams aktualisiert. + +App-Name: ${app_name} +Datum: ${date} + +Team-Name: ${team_name} +Team-ID: ${team_id} + +Team-Management öffnen [${url}]Wenn Sie Fragen haben, dann kontaktieren Sie uns [${support}] bitte. + + +-------------------------------------------------------------------------------- + +Datenschutzrichtlinien und Nutzungsbedingungen [${legal}] · Missbrauch melden +[${misuse}] +${copyright}. ALLE RECHTE VORBEHALTEN. \ No newline at end of file diff --git a/libs/wire-subsystems/templates/de/team/email/idp-config-change.html b/libs/wire-subsystems/templates/de/team/email/idp-config-change.html index dfd90225305..0af625ab652 100644 --- a/libs/wire-subsystems/templates/de/team/email/idp-config-change.html +++ b/libs/wire-subsystems/templates/de/team/email/idp-config-change.html @@ -1 +1 @@ -Die Konfiguration des Identity Providers Ihres Teams hat sich geändert

${brand_label_url}

Änderung in der Konfiguration Ihres Identity Providers

Etwas hat sich in der IdP-Konfiguration für Ihr Team geändert.

Team-ID:
${team_id}

Benutzer-ID:
${user_id}


Details:

IdP-ID:
${idp_id}

Neuer IdP-Aussteller:
${new_idp_issuer}

Neuer IdP-Endpunkt:
${new_idp_endpoint}

Alter IdP-Aussteller:
${old_idp_issuer}

Alter IdP-Endpunkt:
${old_idp_endpoint}


${certificates_details}

Wenn Sie diese Änderung nicht veranlasst haben, wenden Sie sich bitte an den Wire Support.

 

Datenschutzerklärung und Nutzungsbedingungen · Missbrauch melden
${copyright}. Alle Rechte vorbehalten.

                                                           
\ No newline at end of file +Die Konfiguration des Identity Providers Ihres Teams hat sich geändert

${brand_label_url}

Änderung in der Konfiguration Ihres Identity Providers

Etwas hat sich in der IdP-Konfiguration für Ihr Team geändert.

Team ID:
${team_id}

Benutzer-ID:
${user_id}


Details:

IdP ID:
${idp_id}

Neuer IdP-Aussteller:
${new_idp_issuer}

Neuer IdP-Endpunkt:
${new_idp_endpoint}

Alter IdP-Aussteller:
${old_idp_issuer}

Alter IdP-Endpunkt:
${old_idp_endpoint}


${certificates_details}

Wenn Sie diese Änderung nicht veranlasst haben, wenden Sie sich bitte an den Wire Support.

 

Datenschutzerklärung und Nutzungsbedingungen · Missbrauch melden
${copyright}. Alle Rechte vorbehalten.

                                                           
\ No newline at end of file diff --git a/libs/wire-subsystems/templates/de/team/email/idp-config-change.txt b/libs/wire-subsystems/templates/de/team/email/idp-config-change.txt index adaa49b2ec1..32c5a4b05f1 100644 --- a/libs/wire-subsystems/templates/de/team/email/idp-config-change.txt +++ b/libs/wire-subsystems/templates/de/team/email/idp-config-change.txt @@ -5,7 +5,7 @@ ${brand_label_url} [${brand_url}] ÄNDERUNG IN DER KONFIGURATION IHRES IDENTITY PROVIDERS Etwas hat sich in der IdP-Konfiguration für Ihr Team geändert. -Team-ID: +Team ID: ${team_id} Benutzer-ID: @@ -16,7 +16,7 @@ ${user_id} Details: -IdP-ID: +IdP ID: ${idp_id} Neuer IdP-Aussteller: diff --git a/libs/wire-subsystems/templates/en/team/email/app-availability-change-subject.txt b/libs/wire-subsystems/templates/en/team/email/app-availability-change-subject.txt new file mode 100644 index 00000000000..9403b72188f --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-availability-change-subject.txt @@ -0,0 +1 @@ +An app's availability was changed in your team \ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-availability-change.html b/libs/wire-subsystems/templates/en/team/email/app-availability-change.html new file mode 100644 index 00000000000..d90d202462a --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-availability-change.html @@ -0,0 +1 @@ +An app's availability was changed in your team

${brand_label_url}

Availability change for an app

${actor} has changed the availability of an app from your team.

App name: ${app_name}
Date: ${date}

Previous availability: ${previous_availability}
New availability: ${new_availability}

Team name: ${team_name}
Team ID: ${team_id}

If you have any questions, please contact us.

                                                           
\ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-availability-change.txt b/libs/wire-subsystems/templates/en/team/email/app-availability-change.txt new file mode 100644 index 00000000000..96b7360cc16 --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-availability-change.txt @@ -0,0 +1,23 @@ +[${brand_logo}] + +${brand_label_url} [${brand_url}] + +AVAILABILITY CHANGE FOR AN APP +${actor} has changed the availability of an app from your team. + +App name: ${app_name} +Date: ${date} + +Previous availability: ${previous_availability} +New availability: ${new_availability} + +Team name: ${team_name} +Team ID: ${team_id} + +Open Team Management [${url}]If you have any questions, please contact us [${support}]. + + +-------------------------------------------------------------------------------- + +Privacy policy and terms of use [${legal}] · Report Misuse [${misuse}] +${copyright}. ALL RIGHTS RESERVED. \ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-creation-subject.txt b/libs/wire-subsystems/templates/en/team/email/app-creation-subject.txt new file mode 100644 index 00000000000..4d89339c104 --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-creation-subject.txt @@ -0,0 +1 @@ +A new app was created in your team \ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-creation.html b/libs/wire-subsystems/templates/en/team/email/app-creation.html new file mode 100644 index 00000000000..dbce7dd00cc --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-creation.html @@ -0,0 +1 @@ +A new app was created in your team

${brand_label_url}

New app created

${actor} has created an app in your team.

App name: ${app_name}
Date: ${date}

Team name: ${team_name}
Team ID: ${team_id}

Permissions: ${permissions}

If you have any questions, please contact us.

                                                           
\ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-creation.txt b/libs/wire-subsystems/templates/en/team/email/app-creation.txt new file mode 100644 index 00000000000..b842b5d2a23 --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-creation.txt @@ -0,0 +1,22 @@ +[${brand_logo}] + +${brand_label_url} [${brand_url}] + +NEW APP CREATED +${actor} has created an app in your team. + +App name: ${app_name} +Date: ${date} + +Team name: ${team_name} +Team ID: ${team_id} + +Permissions: ${permissions} + +Open Team Management [${url}]If you have any questions, please contact us [${support}]. + + +-------------------------------------------------------------------------------- + +Privacy policy and terms of use [${legal}] · Report Misuse [${misuse}] +${copyright}. ALL RIGHTS RESERVED. \ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-deletion-subject.txt b/libs/wire-subsystems/templates/en/team/email/app-deletion-subject.txt new file mode 100644 index 00000000000..e3927ca55d3 --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-deletion-subject.txt @@ -0,0 +1 @@ +An app was deleted from your team \ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-deletion.html b/libs/wire-subsystems/templates/en/team/email/app-deletion.html new file mode 100644 index 00000000000..c15a47e9979 --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-deletion.html @@ -0,0 +1 @@ +An app was deleted from your team

${brand_label_url}

App deleted

${actor} has deleted an app in your team.

App name: ${app_name}
Date: ${date}

Team name: ${team_name}
Team ID: ${team_id}

If you have any questions, please contact us.

                                                           
\ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-deletion.txt b/libs/wire-subsystems/templates/en/team/email/app-deletion.txt new file mode 100644 index 00000000000..b8ed7906c3a --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-deletion.txt @@ -0,0 +1,20 @@ +[${brand_logo}] + +${brand_label_url} [${brand_url}] + +APP DELETED +${actor} has deleted an app in your team. + +App name: ${app_name} +Date: ${date} + +Team name: ${team_name} +Team ID: ${team_id} + +Open Team Management [${url}]If you have any questions, please contact us [${support}]. + + +-------------------------------------------------------------------------------- + +Privacy policy and terms of use [${legal}] · Report Misuse [${misuse}] +${copyright}. ALL RIGHTS RESERVED. \ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-metadata-change-subject.txt b/libs/wire-subsystems/templates/en/team/email/app-metadata-change-subject.txt new file mode 100644 index 00000000000..8b562d2ea01 --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-metadata-change-subject.txt @@ -0,0 +1 @@ +App details were changed in your team \ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-metadata-change.html b/libs/wire-subsystems/templates/en/team/email/app-metadata-change.html new file mode 100644 index 00000000000..28474657c2a --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-metadata-change.html @@ -0,0 +1 @@ +App details were changed in your team

${brand_label_url}

Details changed for an app

${actor} has changed the details of an app from your team.

New name: ${new_app_name}
Previous name: ${previous_app_name}
Date: ${date}

Team name: ${team_name}
Team ID: ${team_id}

If you have any questions, please contact us.

                                                           
\ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-metadata-change.txt b/libs/wire-subsystems/templates/en/team/email/app-metadata-change.txt new file mode 100644 index 00000000000..1e83365d433 --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-metadata-change.txt @@ -0,0 +1,21 @@ +[${brand_logo}] + +${brand_label_url} [${brand_url}] + +DETAILS CHANGED FOR AN APP +${actor} has changed the details of an app from your team. + +New name: ${new_app_name} +Previous name: ${previous_app_name} +Date: ${date} + +Team name: ${team_name} +Team ID: ${team_id} + +Open Team Management [${url}]If you have any questions, please contact us [${support}]. + + +-------------------------------------------------------------------------------- + +Privacy policy and terms of use [${legal}] · Report Misuse [${misuse}] +${copyright}. ALL RIGHTS RESERVED. \ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-token-change-subject.txt b/libs/wire-subsystems/templates/en/team/email/app-token-change-subject.txt new file mode 100644 index 00000000000..1aac4a1cff2 --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-token-change-subject.txt @@ -0,0 +1 @@ +An app's token was updated in your team \ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-token-change.html b/libs/wire-subsystems/templates/en/team/email/app-token-change.html new file mode 100644 index 00000000000..587e5d038e1 --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-token-change.html @@ -0,0 +1 @@ +An app's token was updated in your team

${brand_label_url}

Updated app authentication token

${actor} has updated the authentication token of an app from your team.

App name: ${app_name}
Date: ${date}

Team name: ${team_name}
Team ID: ${team_id}

If you have any questions, please contact us.

                                                           
\ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-token-change.txt b/libs/wire-subsystems/templates/en/team/email/app-token-change.txt new file mode 100644 index 00000000000..39af4c6383a --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-token-change.txt @@ -0,0 +1,20 @@ +[${brand_logo}] + +${brand_label_url} [${brand_url}] + +UPDATED APP AUTHENTICATION TOKEN +${actor} has updated the authentication token of an app from your team. + +App name: ${app_name} +Date: ${date} + +Team name: ${team_name} +Team ID: ${team_id} + +Open Team Management [${url}]If you have any questions, please contact us [${support}]. + + +-------------------------------------------------------------------------------- + +Privacy policy and terms of use [${legal}] · Report Misuse [${misuse}] +${copyright}. ALL RIGHTS RESERVED. \ No newline at end of file diff --git a/libs/wire-subsystems/templates/version b/libs/wire-subsystems/templates/version index e28f3a1b1bb..a21d12cfba1 100644 --- a/libs/wire-subsystems/templates/version +++ b/libs/wire-subsystems/templates/version @@ -1 +1 @@ -v1.0.148 +v1.0.155 diff --git a/libs/wire-subsystems/test/resources/mails/idp-config-change_created_de.txt b/libs/wire-subsystems/test/resources/mails/idp-config-change_created_de.txt index 8ef7bc4d070..3ba820e07b0 100644 --- a/libs/wire-subsystems/test/resources/mails/idp-config-change_created_de.txt +++ b/libs/wire-subsystems/test/resources/mails/idp-config-change_created_de.txt @@ -5,7 +5,7 @@ wire.example.com [https://wire.example.com] ÄNDERUNG IN DER KONFIGURATION IHRES IDENTITY PROVIDERS Etwas hat sich in der IdP-Konfiguration für Ihr Team geändert. -Team-ID: +Team ID: 99f552d8-9dad-60c1-4be9-c88fb532893a Benutzer-ID: @@ -16,7 +16,7 @@ Benutzer-ID: Details: -IdP-ID: +IdP ID: 574ddfb0-4e50-2bff-e924-33ee2b9f7064 Neuer IdP-Aussteller: diff --git a/libs/wire-subsystems/test/resources/mails/idp-config-change_deleted_de.txt b/libs/wire-subsystems/test/resources/mails/idp-config-change_deleted_de.txt index d3313df47af..570fc5667fe 100644 --- a/libs/wire-subsystems/test/resources/mails/idp-config-change_deleted_de.txt +++ b/libs/wire-subsystems/test/resources/mails/idp-config-change_deleted_de.txt @@ -5,7 +5,7 @@ wire.example.com [https://wire.example.com] ÄNDERUNG IN DER KONFIGURATION IHRES IDENTITY PROVIDERS Etwas hat sich in der IdP-Konfiguration für Ihr Team geändert. -Team-ID: +Team ID: 99f552d8-9dad-60c1-4be9-c88fb532893a Benutzer-ID: @@ -16,7 +16,7 @@ Benutzer-ID: Details: -IdP-ID: +IdP ID: 574ddfb0-4e50-2bff-e924-33ee2b9f7064 Neuer IdP-Aussteller: diff --git a/libs/wire-subsystems/test/resources/mails/idp-config-change_updated_de.txt b/libs/wire-subsystems/test/resources/mails/idp-config-change_updated_de.txt index e7aa88108cf..d4f4a7a295f 100644 --- a/libs/wire-subsystems/test/resources/mails/idp-config-change_updated_de.txt +++ b/libs/wire-subsystems/test/resources/mails/idp-config-change_updated_de.txt @@ -5,7 +5,7 @@ wire.example.com [https://wire.example.com] ÄNDERUNG IN DER KONFIGURATION IHRES IDENTITY PROVIDERS Etwas hat sich in der IdP-Konfiguration für Ihr Team geändert. -Team-ID: +Team ID: 99f552d8-9dad-60c1-4be9-c88fb532893a Benutzer-ID: @@ -16,7 +16,7 @@ Benutzer-ID: Details: -IdP-ID: +IdP ID: 574ddfb0-4e50-2bff-e924-33ee2b9f7064 Neuer IdP-Aussteller: From 7420603cdb9eb4a8666c89ad4880f5b8f8a13ca8 Mon Sep 17 00:00:00 2001 From: Amir Ghezelbash Date: Fri, 31 Jul 2026 14:58:11 +0200 Subject: [PATCH 053/113] WPB-27620: add meeting.member-add notifications (#5383) --------- Co-authored-by: Gautier DI FOLCO --- .../wpb-27620-meeting-member-add.md | 5 + integration/test/Notifications.hs | 4 + integration/test/Test/Meetings.hs | 26 ++- libs/wire-api/src/Wire/API/Event/Meeting.hs | 5 +- .../golden/Test/Wire/API/Golden/Manual.hs | 4 +- .../Wire/API/Golden/Manual/MeetingEvent.hs | 22 +++ ...ect_Event_meeting_member_add_manual_1.json | 19 ++ ...ect_Event_meeting_member_add_manual_2.json | 20 ++ .../Wire/ConversationSubsystem/Federation.hs | 4 +- .../Wire/ConversationSubsystem/Interpreter.hs | 4 +- .../Wire/ConversationSubsystem/MLS/Message.hs | 38 +++- .../src/Wire/MeetingNotifier.hs | 58 ++++++ .../src/Wire/MeetingNotifier/Interpreter.hs | 110 +++++++++++ .../Wire/MeetingNotifier/NoOpInterpreter.hs | 31 ++++ .../src/Wire/MeetingsSubsystem/Interpreter.hs | 56 +----- .../Wire/MeetingsSubsystem/Notification.hs | 60 ++++++ .../Wire/NotificationSubsystem/Interpreter.hs | 14 +- .../test/unit/Wire/MeetingNotifierSpec.hs | 173 ++++++++++++++++++ .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 4 + .../NotificationSubsystem/InterpreterSpec.hs | 28 +++ libs/wire-subsystems/wire-subsystems.cabal | 5 + .../background-worker/src/Wire/Effects.hs | 4 + services/galley/src/Galley/App.hs | 4 + 23 files changed, 637 insertions(+), 61 deletions(-) create mode 100644 changelog.d/2-features/wpb-27620-meeting-member-add.md create mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json create mode 100644 libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json create mode 100644 libs/wire-subsystems/src/Wire/MeetingNotifier.hs create mode 100644 libs/wire-subsystems/src/Wire/MeetingNotifier/Interpreter.hs create mode 100644 libs/wire-subsystems/src/Wire/MeetingNotifier/NoOpInterpreter.hs create mode 100644 libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs create mode 100644 libs/wire-subsystems/test/unit/Wire/MeetingNotifierSpec.hs diff --git a/changelog.d/2-features/wpb-27620-meeting-member-add.md b/changelog.d/2-features/wpb-27620-meeting-member-add.md new file mode 100644 index 00000000000..e87cc9b706b --- /dev/null +++ b/changelog.d/2-features/wpb-27620-meeting-member-add.md @@ -0,0 +1,5 @@ +Added `meeting.member-add` websocket event (WPB-27620). When a user becomes a +member of an MLS meeting conversation, a `meeting.member-add` lifecycle event is +pushed to the newly-added local members, alongside the existing `meeting.create`, +`meeting.update`, and `meeting.delete` events. The payload uses the same meeting +event structure as the other meeting lifecycle events. diff --git a/integration/test/Notifications.hs b/integration/test/Notifications.hs index b04d8f385b6..d842a7991a6 100644 --- a/integration/test/Notifications.hs +++ b/integration/test/Notifications.hs @@ -203,6 +203,10 @@ isMeetingCreateNotif :: (HasCallStack, MakesValue a) => a -> App Bool isMeetingCreateNotif n = fieldEquals n "payload.0.type" "meeting.create" +isMeetingMemberAddNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isMeetingMemberAddNotif n = + fieldEquals n "payload.0.type" "meeting.member-add" + isMeetingUpdateNotif :: (HasCallStack, MakesValue a) => a -> App Bool isMeetingUpdateNotif n = fieldEquals n "payload.0.type" "meeting.update" diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs index 96e0d243353..711732b0f95 100644 --- a/integration/test/Test/Meetings.hs +++ b/integration/test/Test/Meetings.hs @@ -10,7 +10,7 @@ import qualified Data.Text.Encoding as Text import Data.Time.Clock import qualified Data.Time.Format as Time import MLS.Util -import Notifications (isConvCreateMeetingNotif, isMeetingCreateNotif, isMeetingDeleteNotif, isMeetingUpdateNotif) +import Notifications (isConvCreateMeetingNotif, isMeetingCreateNotif, isMeetingDeleteNotif, isMeetingMemberAddNotif, isMeetingUpdateNotif, isMemberJoinNotif, isWelcomeNotif) import SetupHelpers import System.Timeout (timeout) import Testlib.Prelude @@ -73,9 +73,27 @@ testMeetingMLSAddParticipant = do convId <- objConvId conv createGroup def alice1 convId - -- Before the fix, this add commit fails with 403 access-denied at the - -- server (getJSON 201 below throws). After the fix it succeeds. - void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle + memberAddNotif <- + withWebSocket bob $ \ws -> do + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle + let isMeetingAddSequenceNotif notif = do + isMemberJoin <- isMemberJoinNotif notif + isMeetingMemberAdd <- isMeetingMemberAddNotif notif + isWelcome <- isWelcomeNotif notif + pure $ isMemberJoin || isMeetingMemberAdd || isWelcome + sequenceNotifs <- replicateM 3 (awaitMatch isMeetingAddSequenceNotif ws) + sequenceTypes <- for sequenceNotifs $ \notif -> notif %. "payload.0.type" >>= asString + sequenceTypes + `shouldMatch` [ "conversation.member-join", + "meeting.member-add", + "conversation.mls-welcome" + ] + case sequenceNotifs of + [_, notif, _] -> pure notif + _ -> error "expected exactly three meeting-add sequence notifications" + + assertMeetingNotif memberAddNotif (meeting %. "qualified_id") + memberAddNotif %. "payload.0.qualified_conversation" `shouldMatch` convQid bindResponse (getConversation alice convQid) $ \res -> do res.status `shouldMatchInt` 200 diff --git a/libs/wire-api/src/Wire/API/Event/Meeting.hs b/libs/wire-api/src/Wire/API/Event/Meeting.hs index f6eff5a437c..90fd6b25c1e 100644 --- a/libs/wire-api/src/Wire/API/Event/Meeting.hs +++ b/libs/wire-api/src/Wire/API/Event/Meeting.hs @@ -42,7 +42,7 @@ import Wire.Arbitrary (Arbitrary (arbitrary), GenericUniform (..)) -------------------------------------------------------------------------------- -- EventType -data EventType = Create | Update | Delete +data EventType = Create | Update | Delete | MemberAdd deriving stock (Eq, Show, Generic, Enum, Bounded, Ord) deriving (Arbitrary) via (GenericUniform EventType) deriving (FromJSON, ToJSON, S.ToSchema) via Schema EventType @@ -53,7 +53,8 @@ instance ToSchema EventType where mconcat [ element "meeting.create" Create, element "meeting.update" Update, - element "meeting.delete" Delete + element "meeting.delete" Delete, + element "meeting.member-add" MemberAdd ] -------------------------------------------------------------------------------- diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs index 89eefb6e241..1b5ff536427 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs @@ -167,7 +167,9 @@ tests = testObjects [ (testObject_Event_meeting_create_manual_1, "testObject_Event_meeting_create_manual_1.json"), (testObject_Event_meeting_update_manual_1, "testObject_Event_meeting_update_manual_1.json"), - (testObject_Event_meeting_delete_manual_1, "testObject_Event_meeting_delete_manual_1.json") + (testObject_Event_meeting_delete_manual_1, "testObject_Event_meeting_delete_manual_1.json"), + (testObject_Event_meeting_member_add_manual_1, "testObject_Event_meeting_member_add_manual_1.json"), + (testObject_Event_meeting_member_add_manual_2, "testObject_Event_meeting_member_add_manual_2.json") ], testGroup "Meeting V15" $ testObjects diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MeetingEvent.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MeetingEvent.hs index ece72521210..3d0e985b8be 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MeetingEvent.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MeetingEvent.hs @@ -57,3 +57,25 @@ testObject_Event_meeting_delete_manual_1 = evtTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, evtTeam = Nothing } + +testObject_Event_meeting_member_add_manual_1 :: Event +testObject_Event_meeting_member_add_manual_1 = + Event + { evtType = MemberAdd, + evtMeeting = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001")), qDomain = Domain {_domainText = "example.com"}}, + evtConv = Qualified {qUnqualified = Id (fromJust (UUID.fromString "2126ea99-ca79-43ea-ad99-a59616468e8e")), qDomain = Domain {_domainText = "example.com"}}, + evtFrom = EventFromUser $ Qualified {qUnqualified = Id (fromJust (UUID.fromString "a471447c-aa30-4592-81b0-dec6c1c02bca")), qDomain = Domain {_domainText = "example.com"}}, + evtTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + evtTeam = Nothing + } + +testObject_Event_meeting_member_add_manual_2 :: Event +testObject_Event_meeting_member_add_manual_2 = + Event + { evtType = MemberAdd, + evtMeeting = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001")), qDomain = Domain {_domainText = "example.com"}}, + evtConv = Qualified {qUnqualified = Id (fromJust (UUID.fromString "2126ea99-ca79-43ea-ad99-a59616468e8e")), qDomain = Domain {_domainText = "example.com"}}, + evtFrom = EventFromUser $ Qualified {qUnqualified = Id (fromJust (UUID.fromString "a471447c-aa30-4592-81b0-dec6c1c02bca")), qDomain = Domain {_domainText = "example.com"}}, + evtTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + evtTeam = Just (Id (fromJust (UUID.fromString "00000002-0000-0000-0000-000000000002"))) + } diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json new file mode 100644 index 00000000000..8d40ebe09a2 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json @@ -0,0 +1,19 @@ +{ + "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", + "qualified_conversation": { + "domain": "example.com", + "id": "2126ea99-ca79-43ea-ad99-a59616468e8e" + }, + "qualified_from": { + "domain": "example.com", + "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" + }, + "time": "2018-01-01T00:00:00.000Z", + "type": "meeting.member-add", + "via": "user" +} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json new file mode 100644 index 00000000000..628f1bf141e --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json @@ -0,0 +1,20 @@ +{ + "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", + "qualified_conversation": { + "domain": "example.com", + "id": "2126ea99-ca79-43ea-ad99-a59616468e8e" + }, + "qualified_from": { + "domain": "example.com", + "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" + }, + "team": "00000002-0000-0000-0000-000000000002", + "time": "2018-01-01T00:00:00.000Z", + "type": "meeting.member-add", + "via": "user" +} diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Federation.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Federation.hs index 8beac86b3a9..df14feaa080 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Federation.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Federation.hs @@ -94,6 +94,7 @@ import Wire.FederationAPIAccess (FederationAPIAccess) import Wire.FederationSubsystem (FederationSubsystem) import Wire.FireAndForget qualified as E import Wire.LegalHoldStore (LegalHoldStore) +import Wire.MeetingNotifier import Wire.NotificationSubsystem import Wire.ProposalStore (ProposalStore) import Wire.Sem.Now (Now) @@ -575,7 +576,8 @@ handleMLSMessageErrors = . mapToGalleyError @MLSBundleStaticErrors sendMLSCommitBundle :: - ( Member BackendNotificationQueueAccess r, + ( Member MeetingNotifier r, + Member BackendNotificationQueueAccess r, Member BrigAPIAccess r, Member E.ConversationStore r, Member ExternalAccess r, diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs index b8036f25bbd..651d6c775e8 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs @@ -73,6 +73,7 @@ import Wire.FireAndForget (FireAndForget) import Wire.HashPassword (HashPassword) import Wire.JobSubsystem (JobSubsystem) import Wire.LegalHoldStore (LegalHoldStore) +import Wire.MeetingNotifier (MeetingNotifier) import Wire.NotificationSubsystem as NS import Wire.Options.Galley (GuestLinkTTLSeconds) import Wire.ProposalStore (ProposalStore) @@ -86,7 +87,8 @@ import Wire.UserClientIndexStore (UserClientIndexStore) import Wire.UserGroupStore (UserGroupStore) interpretConversationSubsystem :: - ( Member (Error ConversationSubsystemError) r, + ( Member MeetingNotifier r, + Member (Error ConversationSubsystemError) r, Member (Error JSONResponse) r, Member (Error DynError) r, Member UserGroupStore r, diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Message.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Message.hs index 1f61b224b8e..b5cc036c2c6 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Message.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Message.hs @@ -93,6 +93,7 @@ import Wire.ExternalAccess import Wire.FeaturesConfigSubsystem import Wire.FederationAPIAccess import Wire.FederationSubsystem +import Wire.MeetingNotifier import Wire.NotificationSubsystem import Wire.Sem.Now qualified as Now import Wire.Sem.Random (Random) @@ -166,7 +167,8 @@ postMLSMessageFromLocalUser v lusr c conn smsg = do pure $ MLSMessageSendingStatus events t postMLSCommitBundle :: - ( Member (ErrorS MLSLegalholdIncompatible) r, + ( Member MeetingNotifier r, + Member (ErrorS MLSLegalholdIncompatible) r, Member (ErrorS MLSIdentityMismatch) r, Member (Error GroupInfoDiagnostics) r, Member (Error MLSOutOfSyncError) r, @@ -201,7 +203,8 @@ postMLSCommitBundle loc qusr c ctype qConvOrSub conn oosCheck bundle = qConvOrSub postMLSCommitBundleFromLocalUser :: - ( Member (ErrorS MLSLegalholdIncompatible) r, + ( Member MeetingNotifier r, + Member (ErrorS MLSLegalholdIncompatible) r, Member (ErrorS MLSIdentityMismatch) r, Member (Error GroupInfoDiagnostics) r, Member (Error MLSOutOfSyncError) r, @@ -237,7 +240,8 @@ postMLSCommitBundleFromLocalUser v lusr c conn bundle = do pure $ MLSMessageSendingStatus events t postMLSCommitBundleToLocalConv :: - ( Member (ErrorS MLSLegalholdIncompatible) r, + ( Member MeetingNotifier r, + Member (ErrorS MLSLegalholdIncompatible) r, Member (ErrorS MLSIdentityMismatch) r, Member (Error GroupInfoDiagnostics) r, Member (Error MLSOutOfSyncError) r, @@ -382,6 +386,8 @@ postMLSCommitBundleToLocalConv qusr c conn bundle ctype lConvOrSubId = do pure lConvOrSub' pure (events, newClients, lConvOrSub') + notifyNewMeetingMembers qusr lConvOrSub lConvOrSub' + -- send welcome messages for_ bundle.welcome $ \welcome -> sendWelcomes lConvOrSubId qusr conn (cmIdentities newClients) welcome @@ -393,6 +399,32 @@ postMLSCommitBundleToLocalConv qusr c conn bundle ctype lConvOrSubId = do pure events +notifyNewMeetingMembers :: + (Member MeetingNotifier r) => + Qualified UserId -> + Local ConvOrSubConv -> + Local ConvOrSubConv -> + Sem r () +notifyNewMeetingMembers qUser before after = + case (tUnqualified before, tUnqualified after) of + (Conv beforeConv, Conv afterConv) + | isMeetingConv afterConv -> do + let beforeUsers = Set.fromList (map (.id_) beforeConv.mcLocalMembers) + afterUsers = Set.fromList (map (.id_) afterConv.mcLocalMembers) + addedUsers = newLocalMeetingMembers beforeUsers afterUsers + unless (null addedUsers) $ + notifyMeetingMembersAdded + qUser + (Qualified afterConv.mcId (tDomain after)) + afterConv.mcMetadata.cnvmTeam + addedUsers + _ -> pure () + where + isMeetingConv conv = + conv.mcMetadata.cnvmGroupConvType == Just MeetingConversation + newLocalMeetingMembers prev curr = + Set.toList (Set.difference curr prev) + handleGroupInfoMismatch :: (Member (Error GroupInfoDiagnostics) r) => Local ConvOrSubConvId -> diff --git a/libs/wire-subsystems/src/Wire/MeetingNotifier.hs b/libs/wire-subsystems/src/Wire/MeetingNotifier.hs new file mode 100644 index 00000000000..ea54de65b7d --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MeetingNotifier.hs @@ -0,0 +1,58 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MeetingNotifier + ( MeetingNotifier (NotifyMeetingMembersAdded, NotifyMeetingEvent), + notifyMeetingMembersAdded, + notifyMeetingEvent, + ) +where + +import Data.Id +import Data.Qualified (Local, Qualified) +import Imports +import Polysemy +import Wire.API.Event.Meeting qualified as MeetingEvent +import Wire.StoredConversation (LocalMember) + +-- | Interface for all meeting notifications. Routing both the post-commit +-- member-add hook and the create/update/delete lifecycle events through one +-- effect avoids a dependency from the conversation subsystem onto the meetings +-- subsystem. +data MeetingNotifier m a where + -- | Post-commit member-add hook: notify the users added by a successful + -- membership commit to a meeting conversation. Delivered fire-and-forget. + NotifyMeetingMembersAdded :: + Qualified UserId -> + Qualified ConvId -> + Maybe TeamId -> + [UserId] -> + MeetingNotifier m () + -- | Create/update/delete lifecycle event. Delivered synchronously. + NotifyMeetingEvent :: + Local UserId -> + Maybe ConnId -> + [LocalMember] -> + Qualified ConvId -> + Maybe TeamId -> + MeetingEvent.EventType -> + Qualified MeetingId -> + MeetingNotifier m () + +makeSem ''MeetingNotifier diff --git a/libs/wire-subsystems/src/Wire/MeetingNotifier/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingNotifier/Interpreter.hs new file mode 100644 index 00000000000..38d262d3f15 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MeetingNotifier/Interpreter.hs @@ -0,0 +1,110 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MeetingNotifier.Interpreter + ( interpretMeetingNotifier, + ) +where + +import Data.ByteString.Conversion (toByteString') +import Data.Id +import Data.Qualified (Local, Qualified (..), tDomain, tUnqualified) +import Imports +import Polysemy +import Polysemy.TinyLog (TinyLog) +import Polysemy.TinyLog qualified as TinyLog +import System.Logger qualified as Log +import Wire.API.Event.Meeting qualified as MeetingEvent +import Wire.MeetingNotifier +import Wire.MeetingsStore qualified as Store +import Wire.MeetingsSubsystem.Notification +import Wire.NotificationSubsystem +import Wire.Sem.Now (Now) +import Wire.Sem.Now qualified as Now +import Wire.StoredConversation (LocalMember) + +-- | Interpret 'MeetingNotifier'. +interpretMeetingNotifier :: + ( Member Store.MeetingsStore r, + Member NotificationSubsystem r, + Member Now r, + Member TinyLog r + ) => + InterpreterFor MeetingNotifier r +interpretMeetingNotifier = interpret $ \case + NotifyMeetingMembersAdded qUser qConvId mTeamId users -> + notifyMeetingMembersAddedImpl qUser qConvId mTeamId users + NotifyMeetingEvent lUser conn members qConvId mTeamId meetingType qMeetingId -> + notifyMeetingEventImpl lUser conn members qConvId mTeamId meetingType qMeetingId + +-- | Deliver @meeting.member-add@ notifications fire-and-forget via 'pushNotificationAsync'. Resolve each alive meeting for the conversation and notify only the users added by the successful membership commit, logging a warning when no alive meeting is found. +notifyMeetingMembersAddedImpl :: + ( Member Store.MeetingsStore r, + Member NotificationSubsystem r, + Member Now r, + Member TinyLog r + ) => + Qualified UserId -> + Qualified ConvId -> + Maybe TeamId -> + [UserId] -> + Sem r () +notifyMeetingMembersAddedImpl qUser qConvId mTeamId users = do + now <- Now.get + meetings <- Store.listMeetingsByConversation (qUnqualified qConvId) now + when (null meetings) $ + TinyLog.warn $ + Log.msg ("alive meeting not found for meeting member-add event" :: ByteString) + . Log.field "conversationId" (toByteString' (qUnqualified qConvId)) + for_ meetings $ \meeting -> + pushNotificationAsync $ + mkMeetingEventPush + now + qUser + Nothing + (map userRecipient users) + qConvId + mTeamId + MeetingEvent.MemberAdd + (Qualified meeting.id (qDomain qConvId)) + +-- | Deliver a create/update/delete lifecycle event synchronously via 'pushNotifications'. +notifyMeetingEventImpl :: + ( Member NotificationSubsystem r, + Member Now r + ) => + Local UserId -> + Maybe ConnId -> + [LocalMember] -> + Qualified ConvId -> + Maybe TeamId -> + MeetingEvent.EventType -> + Qualified MeetingId -> + Sem r () +notifyMeetingEventImpl lUser conn members qConvId mTeamId meetingType qMeetingId = do + now <- Now.get + pushNotifications + [ mkMeetingEventPush + now + (Qualified (tUnqualified lUser) (tDomain lUser)) + conn + (map localMemberToRecipient members) + qConvId + mTeamId + meetingType + qMeetingId + ] diff --git a/libs/wire-subsystems/src/Wire/MeetingNotifier/NoOpInterpreter.hs b/libs/wire-subsystems/src/Wire/MeetingNotifier/NoOpInterpreter.hs new file mode 100644 index 00000000000..e6b05488253 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MeetingNotifier/NoOpInterpreter.hs @@ -0,0 +1,31 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MeetingNotifier.NoOpInterpreter + ( discardMeetingNotifier, + ) +where + +import Imports +import Polysemy +import Wire.MeetingNotifier + +-- | Interpreter for runtimes which expose no meeting write endpoints. +discardMeetingNotifier :: InterpreterFor MeetingNotifier r +discardMeetingNotifier = interpret $ \case + NotifyMeetingMembersAdded {} -> pure () + NotifyMeetingEvent {} -> pure () diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs index 4dbc07b2dfd..0b45cb2d855 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs @@ -27,7 +27,6 @@ import Data.ByteString.Conversion (toByteString') import Data.Default (def) import Data.Domain (Domain) import Data.Id -import Data.Json.Util (toJSONObject) import Data.Map qualified as Map import Data.Qualified (Local, Qualified (..), inputQualifyLocal, qualifyAs, tDomain, tUnqualified) import Data.Range (Range, unsafeRange) @@ -44,16 +43,15 @@ import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Role (roleNameWireAdmin) import Wire.API.Event.Meeting qualified as MeetingEvent import Wire.API.Meeting qualified as API -import Wire.API.Push.V2 qualified as PushV2 import Wire.API.Routes.MultiTablePaging qualified as MultiTablePaging import Wire.API.Team.Feature (FeatureStatus (..), LockableFeature (..), MeetingsConfig) import Wire.API.User (BaseProtocolTag (BaseProtocolMLSTag), EmailAddress) import Wire.ConversationSubsystem (ConversationSubsystem) import Wire.ConversationSubsystem qualified as ConversationSubsystem import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem, getFeatureForTeam) +import Wire.MeetingNotifier (MeetingNotifier, notifyMeetingEvent) import Wire.MeetingsStore qualified as Store import Wire.MeetingsSubsystem -import Wire.NotificationSubsystem import Wire.Sem.Now (Now) import Wire.Sem.Now qualified as Now import Wire.StoredConversation @@ -107,7 +105,7 @@ interpretMeetingsSubsystem :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, - Member NotificationSubsystem r, + Member MeetingNotifier r, Member Now r, Member TinyLog r, Member (Error MeetingError) r, @@ -140,7 +138,7 @@ createMeetingImpl :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, - Member NotificationSubsystem r, + Member MeetingNotifier r, Member Now r, Member (Error MeetingError) r ) => @@ -203,7 +201,7 @@ createMeetingImpl zUser newMeeting = do trial let qMeetingId = Qualified storedMeeting.id (tDomain zUser) - pushMeetingEvent zUser Nothing storedConv.localMembers (Qualified storedConv.id_ (tDomain zUser)) conversationTeamId MeetingEvent.Create qMeetingId + notifyMeetingEvent zUser Nothing storedConv.localMembers (Qualified storedConv.id_ (tDomain zUser)) conversationTeamId MeetingEvent.Create qMeetingId pure $ storedMeetingToMeetingWithConversation zUser storedConv storedMeeting @@ -212,7 +210,7 @@ updateMeetingImpl :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, - Member NotificationSubsystem r, + Member MeetingNotifier r, Member TinyLog r, Member (Error MeetingError) r, Member Now r @@ -258,7 +256,7 @@ updateMeetingImpl zUser meetingId update validityPeriod = do update.endTime update.recurrence conv <- MaybeT $ getMeetingConversationOrFail meetingId updatedMeeting.conversationId - lift $ pushMeetingEvent zUser Nothing conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId MeetingEvent.Update meetingId + lift $ notifyMeetingEvent zUser Nothing conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId MeetingEvent.Update meetingId pure $ storedMeetingToMeetingWithConversation zUser conv updatedMeeting deleteMeetingImpl :: @@ -266,7 +264,7 @@ deleteMeetingImpl :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, - Member NotificationSubsystem r, + Member MeetingNotifier r, Member TinyLog r, Member (Error MeetingError) r, Member Now r @@ -295,7 +293,7 @@ deleteMeetingImpl zUser connId meetingId validityPeriod = do void $ ConversationSubsystem.deleteLocalConversation zUser connId lConvId lift $ Store.deleteMeeting (qUnqualified meetingId) - lift $ pushMeetingEvent zUser (Just connId) conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId MeetingEvent.Delete meetingId + lift $ notifyMeetingEvent zUser (Just connId) conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId MeetingEvent.Delete meetingId pure $ isJust result getMeetingImpl :: @@ -352,44 +350,6 @@ getMeetingConversationOrFail meetingId convId = do . Log.field "meetingId" (toByteString' (qUnqualified meetingId)) pure Nothing --- | Push a meeting lifecycle event to all local members of the meeting's --- conversation via the 'NotificationSubsystem'. Meetings are not federated, so --- only local members are notified. -pushMeetingEvent :: - ( Member NotificationSubsystem r, - Member Now r - ) => - Local UserId -> - Maybe ConnId -> - [LocalMember] -> - Qualified ConvId -> - Maybe TeamId -> - MeetingEvent.EventType -> - Qualified MeetingId -> - Sem r () -pushMeetingEvent lUser conn members qConvId mTeamId meetingType qMeetingId = do - now <- Now.get - let evt = - MeetingEvent.Event - { evtType = meetingType, - evtMeeting = qMeetingId, - evtConv = qConvId, - evtFrom = - MeetingEvent.EventFromUser - (Qualified (tUnqualified lUser) (tDomain lUser)), - evtTime = now, - evtTeam = mTeamId - } - pushNotifications - [ def - { origin = Just (tUnqualified lUser), - json = toJSONObject evt, - recipients = map localMemberToRecipient members, - route = PushV2.RouteDirect, - conn - } - ] - -- Helper function to convert StoredMeeting to API.Meeting storedMeetingToMeeting :: Domain -> Store.StoredMeeting -> API.Meeting storedMeetingToMeeting domain sm = diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs new file mode 100644 index 00000000000..1be138a47f4 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs @@ -0,0 +1,60 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MeetingsSubsystem.Notification + ( mkMeetingEventPush, + ) +where + +import Data.Default (def) +import Data.Id +import Data.Json.Util (toJSONObject) +import Data.Qualified (Qualified (..)) +import Data.Time.Clock (UTCTime) +import Imports +import Wire.API.Event.Meeting qualified as MeetingEvent +import Wire.API.Push.V2 qualified as PushV2 +import Wire.NotificationSubsystem + +-- | Build the common push event structure used by all meeting lifecycle events. +mkMeetingEventPush :: + UTCTime -> + Qualified UserId -> + Maybe ConnId -> + [Recipient] -> + Qualified ConvId -> + Maybe TeamId -> + MeetingEvent.EventType -> + Qualified MeetingId -> + Push +mkMeetingEventPush now qUser conn recipients qConvId mTeamId meetingType qMeetingId = + def + { origin = Just (qUnqualified qUser), + json = + toJSONObject + MeetingEvent.Event + { evtType = meetingType, + evtMeeting = qMeetingId, + evtConv = qConvId, + evtFrom = MeetingEvent.EventFromUser qUser, + evtTime = now, + evtTeam = mTeamId + }, + recipients, + route = PushV2.RouteDirect, + conn + } diff --git a/libs/wire-subsystems/src/Wire/NotificationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/NotificationSubsystem/Interpreter.hs index adb170507d4..ca42455ee0f 100644 --- a/libs/wire-subsystems/src/Wire/NotificationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/NotificationSubsystem/Interpreter.hs @@ -91,7 +91,19 @@ pushAsyncImpl :: ) => Push -> Sem r (Async (Maybe ())) -pushAsyncImpl p = async $ do +pushAsyncImpl p = async $ pushBestEffort p + +pushBestEffort :: + forall r. + ( Member GundeckAPIAccess r, + Member (Input NotificationSubsystemConfig) r, + Member P.Async r, + Member (Final IO) r, + Member P.TinyLog r + ) => + Push -> + Sem r () +pushBestEffort p = do reqId <- inputs requestId errorToIOFinal @SomeException (fromExceptionSem @SomeException $ pushImpl [p]) >>= \case Left e -> diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingNotifierSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingNotifierSpec.hs new file mode 100644 index 00000000000..e78591e5f50 --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/MeetingNotifierSpec.hs @@ -0,0 +1,173 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MeetingNotifierSpec (spec) where + +import Control.Concurrent.Async qualified as A +import Data.Aeson (Result (..), Value (Object), fromJSON) +import Data.Domain (Domain (..)) +import Data.Id +import Data.Qualified (Qualified (..)) +import Data.Range (unsafeRange) +import Data.Time.Calendar (Day (ModifiedJulianDay)) +import Data.Time.Clock (UTCTime (..), addUTCTime) +import Imports +import Polysemy +import Polysemy.State +import Polysemy.TinyLog (TinyLog) +import Test.Hspec +import Wire.API.Event.Meeting qualified as MeetingEvent +import Wire.MeetingNotifier +import Wire.MeetingNotifier.Interpreter +import Wire.MeetingsStore qualified as Store +import Wire.MockInterpreters.Now (interpretNowConst) +import Wire.NotificationSubsystem +import Wire.Sem.Logger.TinyLog (discardTinyLogs) +import Wire.Sem.Now (Now) + +spec :: Spec +spec = do + describe "interpretMeetingNotifier" $ do + it "pushes a member-add event for each alive meeting" $ do + let now = UTCTime (ModifiedJulianDay 60000) 0 + actor = Id $ read "00000000-0000-0000-0000-000000000001" + addedUser = Id $ read "00000000-0000-0000-0000-000000000002" + convId = Id $ read "00000000-0000-0000-0000-000000000003" + meetingId = Id $ read "00000000-0000-0000-0000-000000000004" + domain = Domain "local.example.com" + meeting = storedMeeting meetingId convId now (addUTCTime 60 now) + expectedEvent = + MeetingEvent.Event + { evtType = MeetingEvent.MemberAdd, + evtMeeting = Qualified meetingId domain, + evtConv = Qualified convId domain, + evtFrom = MeetingEvent.EventFromUser (Qualified actor domain), + evtTime = now, + evtTeam = Nothing + } + + pushes <- + runMemberAdded now [meeting] $ + notifyMeetingMembersAdded + (Qualified actor domain) + (Qualified convId domain) + Nothing + [addedUser] + + length pushes `shouldBe` 1 + let push = head pushes + push.recipients `shouldBe` [userRecipient addedUser] + case fromJSON (Object push.json) :: Result MeetingEvent.Event of + Error err -> expectationFailure err + Success event -> event `shouldBe` expectedEvent + + it "does not push when no alive meeting exists" $ do + let now = UTCTime (ModifiedJulianDay 60000) 0 + actor = Id $ read "00000000-0000-0000-0000-000000000001" + addedUser = Id $ read "00000000-0000-0000-0000-000000000002" + convId = Id $ read "00000000-0000-0000-0000-000000000003" + domain = Domain "local.example.com" + + pushes <- + runMemberAdded now [] $ + notifyMeetingMembersAdded + (Qualified actor domain) + (Qualified convId domain) + Nothing + [addedUser] + + pushes `shouldBe` [] + + it "does not push when the alive meeting belongs to a different conversation" $ do + let now = UTCTime (ModifiedJulianDay 60000) 0 + actor = Id $ read "00000000-0000-0000-0000-000000000001" + addedUser = Id $ read "00000000-0000-0000-0000-000000000002" + convId = Id $ read "00000000-0000-0000-0000-000000000003" + otherConvId = Id $ read "00000000-0000-0000-0000-000000000005" + meetingId = Id $ read "00000000-0000-0000-0000-000000000004" + domain = Domain "local.example.com" + meeting = storedMeeting meetingId otherConvId now (addUTCTime 60 now) + + pushes <- + runMemberAdded now [meeting] $ + notifyMeetingMembersAdded + (Qualified actor domain) + (Qualified convId domain) + Nothing + [addedUser] + + pushes `shouldBe` [] + +runMemberAdded :: + UTCTime -> + [Store.StoredMeeting] -> + Sem + '[ MeetingNotifier, + Store.MeetingsStore, + NotificationSubsystem, + Now, + TinyLog, + State [Push], + Embed IO + ] + () -> + IO [Push] +runMemberAdded now meetings = + runM + . execState ([] :: [Push]) + . discardTinyLogs + . interpretNowConst now + . captureNotifications + . interpretMeetingsStore meetings + . interpretMeetingNotifier + +captureNotifications :: + (Member (State [Push]) r, Member (Embed IO) r) => + InterpreterFor NotificationSubsystem r +captureNotifications = interpret $ \case + PushNotificationAsync push -> + modify (<> [push]) >> embed (A.async (pure (Just ()))) + _ -> error "unexpected notification operation" + +interpretMeetingsStore :: + [Store.StoredMeeting] -> + InterpreterFor Store.MeetingsStore r +interpretMeetingsStore meetings = interpret $ \case + Store.ListMeetingsByConversation convId _ -> + pure (filter ((== convId) . Store.conversationId) meetings) + _ -> error "unexpected meetings store operation" + +storedMeeting :: + MeetingId -> + ConvId -> + UTCTime -> + UTCTime -> + Store.StoredMeeting +storedMeeting meetingId convId startTime endTime = + Store.StoredMeeting + { Store.id = meetingId, + Store.title = unsafeRange "Meeting", + Store.creator = Id $ read "00000000-0000-0000-0000-000000000001", + Store.startTime = startTime, + Store.endTime = endTime, + Store.recurrence = Nothing, + Store.conversationId = convId, + Store.invitedEmails = [], + Store.trial = False, + Store.createdAt = startTime, + Store.updatedAt = startTime + } diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index b0e60869a12..904006b7c87 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -52,6 +52,8 @@ import Wire.API.Team.Permission (fullPermissions) import Wire.ConversationSubsystem import Wire.FeaturesConfigSubsystem import Wire.GalleyAPIAccess (GalleyAPIAccess) +import Wire.MeetingNotifier (MeetingNotifier) +import Wire.MeetingNotifier.Interpreter (interpretMeetingNotifier) import Wire.MeetingsStore qualified as Store import Wire.MeetingsSubsystem import Wire.MeetingsSubsystem.Interpreter @@ -66,6 +68,7 @@ import Wire.TeamSubsystem.GalleyAPI type TestStack = '[ MeetingsSubsystem, + MeetingNotifier, Store.MeetingsStore, ConversationSubsystem, TeamSubsystem, @@ -133,6 +136,7 @@ runTestStack now gen teams configs = . interpretTeamSubsystemToGalleyAPI . inMemoryConversationSubsystemInterpreter . inMemoryMeetingsStoreInterpreter + . interpretMeetingNotifier . interpretMeetingsSubsystem 3600 -- | Decode all 'Push' payloads that are meeting lifecycle events. Any push that diff --git a/libs/wire-subsystems/test/unit/Wire/NotificationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/NotificationSubsystem/InterpreterSpec.hs index a069501934f..e021cc7e119 100644 --- a/libs/wire-subsystems/test/unit/Wire/NotificationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/NotificationSubsystem/InterpreterSpec.hs @@ -236,6 +236,34 @@ spec = describe "NotificationSubsystem.Interpreter" do map fst logs `shouldBe` [Error] cs (head (map snd logs)) `shouldContain` "error=TestException" + describe "pushBestEffort" do + it "logs errors without failing the caller" do + let mockConfig = + NotificationSubsystemConfig + { fanoutLimit = toRange $ Proxy @30, + chunkSize = 12, + slowPushDelay = 1, + requestId = RequestId defRequestId + } + + user1 <- generate arbitrary + payload1 <- generate $ resize 1 arbitrary + clients1 <- generate $ resize 3 arbitrary + let push1 = + def + { transient = True, + route = V2.RouteDirect, + recipients = [Recipient user1 (V2.RecipientClientsSome clients1)], + json = payload1 + } + (_, attemptedPushes, logs) <- + runMiniStackAsync mockConfig $ + pushBestEffort push1 + + attemptedPushes `shouldBe` [[toV2Push push1]] + map fst logs `shouldBe` [Error] + cs (head (map snd logs)) `shouldContain` "error=TestException" + describe "toV2Push" do it "does the transformation correctly" $ property \(pushToUser :: Push) -> let v2Push = toV2Push pushToUser diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 001df802a52..dfa99c46f69 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -387,10 +387,14 @@ library Wire.LegalHoldStore.Env Wire.ListItems Wire.ListItems.Team.Cassandra + Wire.MeetingNotifier + Wire.MeetingNotifier.Interpreter + Wire.MeetingNotifier.NoOpInterpreter Wire.MeetingsStore Wire.MeetingsStore.Postgres Wire.MeetingsSubsystem Wire.MeetingsSubsystem.Interpreter + Wire.MeetingsSubsystem.Notification Wire.Migration Wire.MigrationLock Wire.MlsKeyPackageStore @@ -619,6 +623,7 @@ test-suite wire-subsystems-tests Wire.FederationSubsystem.InternalsSpec Wire.HashPassword.InterpreterSpec Wire.IdPSubsystem.InterpreterSpec + Wire.MeetingNotifierSpec Wire.MeetingsSubsystem.InterpreterSpec Wire.MiniBackend Wire.MockInterpreters diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index 088ba777774..0d367f19eed 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -96,6 +96,8 @@ import Wire.JobSubsystem.Interpreter (interpretJobSubsystem) import Wire.LegalHoldStore (LegalHoldStore) import Wire.LegalHoldStore.Cassandra (interpretLegalHoldStoreToCassandra) import Wire.LegalHoldStore.Env (LegalHoldEnv (..)) +import Wire.MeetingNotifier (MeetingNotifier) +import Wire.MeetingNotifier.NoOpInterpreter (discardMeetingNotifier) import Wire.MigrationLock (MigrationLockError) import Wire.NotificationSubsystem (NotificationSubsystem) import Wire.NotificationSubsystem.Interpreter @@ -191,6 +193,7 @@ makeVerifiedRequestFreshManagerIO logger fpr url reqBuilder = do type BackgroundWorkerEffects = '[ ConversationSubsystem, + MeetingNotifier, TeamCollaboratorsSubsystem, Input AllTeamFeatures, FeaturesConfigSubsystem, @@ -365,6 +368,7 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . runFeaturesConfigSubsystem . runInputSem getAllTeamFeaturesForServer . interpretTeamCollaboratorsSubsystem + . discardMeetingNotifier . interpretConversationSubsystem where interpretTeamFeatureStore = case env.postgresMigration.teamFeatures of diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index 010ea8cf59c..1fec2aa4939 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -135,6 +135,8 @@ import Wire.ListItems.Team.Cassandra ( interpretInternalTeamListToCassandra, interpretTeamListToCassandra, ) +import Wire.MeetingNotifier (MeetingNotifier) +import Wire.MeetingNotifier.Interpreter (interpretMeetingNotifier) import Wire.MeetingsStore (MeetingsStore) import Wire.MeetingsStore.Postgres (interpretMeetingsStoreToPostgres) import Wire.MeetingsSubsystem (MeetingsSubsystem) @@ -194,6 +196,7 @@ import Wire.UserGroupStore.Postgres (interpretUserGroupStoreToPostgres) type GalleyEffects = '[ MeetingsSubsystem, ConversationSubsystem, + MeetingNotifier, JobSubsystem, Input RequestId, FederationSubsystem, @@ -559,6 +562,7 @@ evalGalley e = JobSubsystemConfig { jobSubsystemSchemaName = ArbiterCore.defaultSchemaName } + . interpretMeetingNotifier . interpretConversationSubsystem . Meeting.interpretMeetingsSubsystem meetingValidityPeriod where From f07b9039e8094d103f495073cd57534458981af6 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 31 Jul 2026 15:35:54 +0200 Subject: [PATCH 054/113] [WPB-27705] Roll back: do *not* include collaborator apps in get-apps end-point. (#5402) * Revert "[WPB-27227] Include collaborating apps (aka external apps) in "GET /teams/:tid/apps". (#5343)" This partially reverts commits 3cb74c433d07689d8c25f5a7894d4fc2053e2725, 632be415979f0bc13d29f29c0877d11cfa10459c, but leaves in some drive-by changes. --- ...-include-collaborator-apps-in-get-apps-end-point | 1 + ...ps-_aka-external-apps_-in-_get-_teams__tid_apps_ | 1 - .../src/Wire/UserSubsystem/Interpreter.hs | 13 ++----------- .../test/unit/Wire/UserSubsystem/InterpreterSpec.hs | 4 ++-- 4 files changed, 5 insertions(+), 14 deletions(-) create mode 100644 changelog.d/1-api-changes/WPB-27705-roll-back_-do-_not_-include-collaborator-apps-in-get-apps-end-point delete mode 100644 changelog.d/3-bug-fixes/WPB-27227-include-collaborating-apps-_aka-external-apps_-in-_get-_teams__tid_apps_ diff --git a/changelog.d/1-api-changes/WPB-27705-roll-back_-do-_not_-include-collaborator-apps-in-get-apps-end-point b/changelog.d/1-api-changes/WPB-27705-roll-back_-do-_not_-include-collaborator-apps-in-get-apps-end-point new file mode 100644 index 00000000000..a2c92cadfba --- /dev/null +++ b/changelog.d/1-api-changes/WPB-27705-roll-back_-do-_not_-include-collaborator-apps-in-get-apps-end-point @@ -0,0 +1 @@ +Roll back: do *not* include collaborator apps in get-apps end-point. diff --git a/changelog.d/3-bug-fixes/WPB-27227-include-collaborating-apps-_aka-external-apps_-in-_get-_teams__tid_apps_ b/changelog.d/3-bug-fixes/WPB-27227-include-collaborating-apps-_aka-external-apps_-in-_get-_teams__tid_apps_ deleted file mode 100644 index 1a63e63613b..00000000000 --- a/changelog.d/3-bug-fixes/WPB-27227-include-collaborating-apps-_aka-external-apps_-in-_get-_teams__tid_apps_ +++ /dev/null @@ -1 +0,0 @@ -Include collaborating apps (aka external apps) in "GET /teams/:tid/apps". (Drive-by improvement: move access control for UserSubsystem.GetLocalAppProfiles from brig into wire-subsystems.) diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index d85eb1fbe26..7873ab84b3e 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -60,7 +60,6 @@ import Wire.API.Federation.Error import Wire.API.MLS.CipherSuite (CipherSuiteTag, csSignatureScheme) import Wire.API.Routes.FederationDomainConfig import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti (TeamStatus (..)) -import Wire.API.Team.Collaborator import Wire.API.Team.Export import Wire.API.Team.Feature import Wire.API.Team.Member @@ -103,7 +102,6 @@ import Wire.Sem.Metrics qualified as Metrics import Wire.Sem.Now (Now) import Wire.Sem.Now qualified as Now import Wire.StoredUser -import Wire.TeamCollaboratorsSubsystem import Wire.TeamSubsystem import Wire.UserGroupStore (UserGroupStore, getUserGroupIdsForUsers) import Wire.UserKeyStore @@ -118,8 +116,7 @@ import Wire.UserSubsystem.UserSubsystemConfig import Witherable (wither) runUserSubsystem :: - ( Member TeamCollaboratorsSubsystem r, - Member AppStore r, + ( Member AppStore r, Member UserStore r, Member ClientStore r, Member MlsKeyPackageSubsystem r, @@ -438,7 +435,6 @@ getLocalAppProfilesImpl :: Member (Concurrency Unsafe) r, Member (Input (Local any)) r, Member AppSubsystem r, - Member TeamCollaboratorsSubsystem r, Member TeamSubsystem r ) => Local UserId -> @@ -462,12 +458,7 @@ getLocalAppProfilesImpl self tid = do Just app -> profile {profileApp = Just (storedAppToAppInfo app)} Nothing -> profile - collaboratingApps :: [UserProfile] <- do - allIds <- (.gUser) <$$> getAllTeamCollaborators self tid - allProfiles <- getUserProfilesLocalPart (Just self) (qualifyAs self allIds) - pure (filter (isJust . (.profileApp)) allProfiles) - - pure ((injectPreloadedApp <$> profiles) <> collaboratingApps) + pure (injectPreloadedApp <$> profiles) getUserProfilesFromDomain :: ( Member (Error FederationError) r, diff --git a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs index 28c120822f4..68942a77cb3 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs @@ -1145,7 +1145,7 @@ spec = describe "UserSubsystem.Interpreter" do pure $ result.searchResults === [expectedContact | fromMaybe True searchee.searchable] describe "getLocalAppProfiles" $ do - prop "includes apps that are collaborators from other teams" $ + prop "does *not* include apps that are collaborators from other teams" $ \(NotPendingStoredUser appUser_) (NotPendingStoredUser ownerA_) (NotPendingStoredUser ownerB_) @@ -1198,7 +1198,7 @@ spec = describe "UserSubsystem.Interpreter" do in (,) <$> getAppId teamAId teamAOwnerId <*> getAppId teamBId teamBOwnerId - in result === ([appUser.id], [appUser.id]) + in result === ([], [appUser.id]) prop "denies access when the caller's own team is not the requested team" . withMaxSuccess 1 $ \(NotPendingStoredUser caller_) From 681f4b5bab3122885b1d4e4b639cd07bd468ca82 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Mon, 3 Aug 2026 14:36:26 +0200 Subject: [PATCH 055/113] integration: Remove hardcoded prekeys (#5407) --- integration/default.nix | 2 + integration/integration.cabal | 1 + integration/test/API/BrigCommon.hs | 1 + integration/test/Test/Bot.hs | 1 + integration/test/Test/LegalHold.hs | 6 +- integration/test/Testlib/App.hs | 22 ---- integration/test/Testlib/Env.hs | 5 - .../test/Testlib/MockIntegrationService.hs | 3 +- integration/test/Testlib/Prekeys.hs | 106 +++++++----------- integration/test/Testlib/Types.hs | 2 - 10 files changed, 49 insertions(+), 100 deletions(-) diff --git a/integration/default.nix b/integration/default.nix index 8ef7ea4b4c6..8290943dee2 100644 --- a/integration/default.nix +++ b/integration/default.nix @@ -17,6 +17,7 @@ , bytestring-conversion , Cabal , case-insensitive +, cborg , containers , cookie , cql @@ -123,6 +124,7 @@ mkDerivation { bytestring bytestring-conversion case-insensitive + cborg containers cookie cql diff --git a/integration/integration.cabal b/integration/integration.cabal index 92abb7662b7..67871bcd465 100644 --- a/integration/integration.cabal +++ b/integration/integration.cabal @@ -261,6 +261,7 @@ library , bytestring , bytestring-conversion , case-insensitive + , cborg , containers , cookie , cql diff --git a/integration/test/API/BrigCommon.hs b/integration/test/API/BrigCommon.hs index cda7084d5ff..4b339314e02 100644 --- a/integration/test/API/BrigCommon.hs +++ b/integration/test/API/BrigCommon.hs @@ -20,6 +20,7 @@ module API.BrigCommon where import API.Common import Data.Aeson.Types (Pair) import Data.Maybe +import Testlib.Prekeys import Testlib.Prelude as Prelude data AddClient = AddClient diff --git a/integration/test/Test/Bot.hs b/integration/test/Test/Bot.hs index f96bc7733ae..6fb579abae1 100644 --- a/integration/test/Test/Bot.hs +++ b/integration/test/Test/Bot.hs @@ -38,6 +38,7 @@ import Servant.Server import SetupHelpers import Testlib.Certs import Testlib.MockIntegrationService +import Testlib.Prekeys import Testlib.Prelude import UnliftIO diff --git a/integration/test/Test/LegalHold.hs b/integration/test/Test/LegalHold.hs index 7da2c2dfa24..2fc8629bb80 100644 --- a/integration/test/Test/LegalHold.hs +++ b/integration/test/Test/LegalHold.hs @@ -253,11 +253,13 @@ testLHClaimKeys approvedOrPending testmode = do LHApproved -> approveLegalHoldDevice ltid (lmem %. "qualified_id") defPassword >>= assertSuccess LHPending -> pure () + pks <- getPrekeys 10 + lpk <- getLastPrekey let addc caps = addClient pmem (settings caps) >>= assertSuccess settings caps = def - { prekeys = Just $ take 10 somePrekeysRendered, - lastPrekey = Just $ head someLastPrekeysRendered, + { prekeys = Just pks, + lastPrekey = Just lpk, acapabilities = caps } in addc $ Just ["legalhold-implicit-consent"] diff --git a/integration/test/Testlib/App.hs b/integration/test/Testlib/App.hs index d44523fcc98..55e6878b865 100644 --- a/integration/test/Testlib/App.hs +++ b/integration/test/Testlib/App.hs @@ -23,7 +23,6 @@ import Control.Monad.Trans.Maybe (MaybeT (MaybeT), runMaybeT) import qualified Control.Retry as Retry import Data.Aeson hiding ((.=)) import Data.Bool (bool) -import Data.IORef import Data.Maybe (isJust) import qualified Data.Text as T import qualified Data.Yaml as Yaml @@ -38,27 +37,6 @@ import Prelude failApp :: (HasCallStack) => String -> App a failApp msg = throw (AppFailure msg callStack) -getPrekey :: App Value -getPrekey = App $ do - pks <- asks (.prekeys) - (i, pk) <- liftIO $ atomicModifyIORef pks getPK - pure $ object ["id" .= i, "key" .= pk] - where - getPK [] = error "Out of prekeys" - getPK (k : ks) = (ks, k) - -getLastPrekey :: App Value -getLastPrekey = App $ do - pks <- asks (.lastPrekeys) - lpk <- liftIO $ atomicModifyIORef pks getPK - pure $ object ["id" .= lastPrekeyId, "key" .= lpk] - where - getPK [] = error "No last prekey left" - getPK (k : ks) = (ks, k) - - lastPrekeyId :: Int - lastPrekeyId = 65535 - readServiceConfig :: Service -> App Value readServiceConfig = readServiceConfig' . configName diff --git a/integration/test/Testlib/Env.hs b/integration/test/Testlib/Env.hs index c242d453245..2ace4753b82 100644 --- a/integration/test/Testlib/Env.hs +++ b/integration/test/Testlib/Env.hs @@ -42,7 +42,6 @@ import System.Exit import System.FilePath import System.IO import System.IO.Temp -import Testlib.Prekeys import Testlib.ResourcePool import Testlib.Types import Text.Read (readMaybe) @@ -168,8 +167,6 @@ mkEnv :: Maybe String -> GlobalEnv -> Codensity IO Env mkEnv currentTestName ge = do mls <- liftIO . newIORef =<< mkMLSState liftIO $ do - pks <- newIORef (zip [1 ..] somePrekeys) - lpks <- newIORef someLastPrekeys curlTrace <- newIORef [] reqId <- newIORef 0 pure @@ -195,8 +192,6 @@ mkEnv currentTestName ge = do ], manager = gManager ge, servicesCwdBase = gServicesCwdBase ge, - prekeys = pks, - lastPrekeys = lpks, mls = mls, resourcePool = ge.gBackendResourcePool, rabbitMQConfig = ge.gRabbitMQConfig, diff --git a/integration/test/Testlib/MockIntegrationService.hs b/integration/test/Testlib/MockIntegrationService.hs index 7962fb4052f..f632ad93d72 100644 --- a/integration/test/Testlib/MockIntegrationService.hs +++ b/integration/test/Testlib/MockIntegrationService.hs @@ -45,6 +45,7 @@ import Network.Wai as Wai import qualified Network.Wai.Handler.Warp as Warp import qualified Network.Wai.Handler.Warp.Internal as Warp import qualified Network.Wai.Handler.WarpTLS as Warp +import Testlib.Prekeys import Testlib.Prelude hiding (IntegrationConfig (integrationTestHostName)) import UnliftIO (MonadUnliftIO (withRunInIO)) import UnliftIO.Async @@ -127,7 +128,7 @@ instance (App ~ f) => Default (CreateMock f) where def = MkCreateMock { nextLastPrey = getLastPrekey, - somePrekeys = replicateM 3 getPrekey + somePrekeys = getPrekeys 3 } data LhApiVersion = V0 | V1 diff --git a/integration/test/Testlib/Prekeys.hs b/integration/test/Testlib/Prekeys.hs index 61912da701f..a37331ef19e 100644 --- a/integration/test/Testlib/Prekeys.hs +++ b/integration/test/Testlib/Prekeys.hs @@ -16,83 +16,53 @@ -- with this program. If not, see . module Testlib.Prekeys - ( somePrekeys, - someLastPrekeys, - somePrekeysRendered, - someLastPrekeysRendered, + ( getPrekey, + getPrekeys, + getLastPrekey, ) where +import qualified Codec.CBOR.Encoding as CBOR +import qualified Codec.CBOR.Write as CBOR +import qualified Crypto.PubKey.Ed25519 as Ed25519 +import qualified Crypto.Random as Crypto import Data.Aeson +import qualified Data.ByteArray as ByteArray +import qualified Data.ByteString.Base64 as Base64 import Data.String +import Data.String.Conversions (cs) import Data.Word import Prelude --- | FUTUREWORK: client ids are calculated from prekeys in brig, so we should have a more --- robust mechanism to pick them, to avoid id clashes as well as running out of list. just --- call cryptobox? (or fake it, find out where the id is encoded in the key payload, count --- that inside an MVar, and return the same key with different id every time?) -somePrekeys :: [String] -somePrekeys = - [ "pQABAQECoQBYIOjl7hw0D8YRNqkkBQETCxyr7/ywE/2R5RWcUPM+GJACA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABAQICoQBYIGoXawUQWQ9ZW+MXhvuo9ALOBUjLff8S5VdAokN29C1OA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABAQMCoQBYIEjdt+YWd3lHmG8pamULLMubAMZw556IO8kW7s1MLFytA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABAQQCoQBYIPIaOA3Xqfk4Lh2/pU88Owd2eW5eplHpywr+Mx4QGyiMA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABAQUCoQBYIHnafNR4Gh3ID71lYzToewEVag4EKskDFq+gaeraOlSJA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABAQYCoQBYIFXUkVftE7kK22waAzhOjOmJVex3EBTU8RHZFx2o1Ed8A6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABAQcCoQBYIDXdN8VlKb5lbgPmoDPLPyqNIEyShG4oT/DlW0peRRZUA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABAQgCoQBYIJH1ewvIVV3yGqQvdr/QM9HARzMgo5ksOTRyKEuN2aZzA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABAQkCoQBYIFcAnXdx0M1Q1hoDDfgMK9r+Zchn8YlVHHaQwQYhRk1dA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABAQoCoQBYIGs3vyxwmzEZ+qKNy4wpFkxc+Bgkb0D76ZEbxeeh/9DVA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABAQsCoQBYIGUiBeOJALP5dkMduUZ/u6MDhHNrsrBUa3f0YlSSWZbzA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABAQwCoQBYIMp6QNNTPDZgL3DSSD/QWWnBI7LsTZp2RhY/HLqnIwRZA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABAQ0CoQBYIJXSSUrE5RCNyB5pg+m6vGwK7RvJ+rs9dsdHitxnfDhuA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABAQ4CoQBYIHmtOX7jCKBHFDysb4H0z/QWoCSaEyjerZaT/HOP8bgDA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABAQ8CoQBYIIaMCTcPKj2HuYQ7i9ZaxUw9j5Bz8TPjoAaTZ5eB0w1kA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABARACoQBYIHWAOacKuWH81moJVveJ0FSfipWocfspOIBhaU6VLWUsA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABARECoQBYIA8XtUXtnMxQslULnNAeHBIivlLRe/+qdh2j6nTfDAchA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABARICoQBYIGgzg6SzgTTOgnk48pa6y2Rgjy004DkeBo4CMld3Jlr6A6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABARMCoQBYIEoEFiIpCHgn74CAD+GhIfIgbQtdCqQqkOXHWxRlG6Y6A6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABARQCoQBYINVEwTRxNSe0rxZxon4Rifz2l4rtQZn7mHtKYCiFAK9IA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABARUCoQBYIN3aeX2Ayi2rPFbiaYb+O2rdHUpFhzRs2j28pCmbGpflA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=", - "pQABARYCoQBYIJe5OJ17YKQrNmIH3sE++r++4Z5ld36axqAMjjQ3jtQWA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=" - ] +getPrekey :: (Crypto.MonadRandom m) => m Value +getPrekey = mkPrekey 1 -someLastPrekeys :: [String] -someLastPrekeys = - [ "pQABARn//wKhAFggnCcZIK1pbtlJf4wRQ44h4w7/sfSgj5oWXMQaUGYAJ/sDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggwO2any+CjiGP8XFYrY67zHPvLgp+ysY5k7vci57aaLwDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggoChErA5oTI5JT769hJV+VINmU8kougGdYqGd2U7hPa8DoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggPLk4BBJ8THVLGm7r0K7EJITRlJnt6bpNzM9GTNRYcCcDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggqHASsRlZ1i8dESXRXBL2OvR+0yGUtqK9vJfzol1E+osDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggx/N1YhKXSJYJQxhWgHSA4ASaJKIHDJfmEnojfnp9VQ8DoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggVL6QIpoqmtKxmB8HToiAPxfjSDEzJEUAoFKfhXou06YDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggRs74/ViOrHN+aS2RbGCwC0sJv1Sp/Q0pmRB15s9DCBMDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggtNO/hrwzt9M/1X6eK2sG6YFmA7BDqlFMEipbZOsg0vcDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFgg1rZEY6vbAnEz+Ern5kRny/uKiIrXTb/usQxGnceV2HADoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFgg2647mOAVeOdhW57Q1zXDigDxRz/hB8ITFSZ7uo+pXH4DoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggjddbHizABYOY0T6rvJeZCvV20dvTT9BYv95ri9bqSb8DoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggCKT/GspZquUY6vKC4TFvaFqTH1QGG1ptauiaulnfqkUDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggv7bf/kEsTKFDGSgswsywq6AIxBq5AqZbLjDYDHfGjrcDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggUbjGhhh8EwZEPSz+Y31rYNUu7jsRR8dy1F5FSiJXfXEDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFgg/4nz1uHiPBVGFvYjTMwGQ31bSFNctbU0r2nBtpsK9kcDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggwbJDyKl7T3+3Ihc0YF06Dz2J11My5qn7JKG+U+ti8lQDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFgglc6nCoZR2/qjLp0tr7vRyuXqb7ugdHHDadjX7zSl4uMDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFgg5ER8h0/bIADXjBXe/XPKdzekgv6nhJ4hp3vJ3jtTSbUDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggsgV6jq+GuNuvXk+ctHh570cNqEmfPhz34wcYCMCf9xIDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggdQdlPqkBw6+phKhohp3YaWQL710euZDnyMLFwf2cS0oDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggKlsI/snuQMoYcZRw/kN+BobPV5gwYeBClp0Wx9btTGUDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggtruFBClEgdPKvjpHsYLlWMev9L4OmYZwlxbY0NwvzOwDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggRUdh4cuYtFNL46RLnPy65goYInyreStKwsEcY3pPlLkDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggQtT7lLZzH171F4jCbHNwxEAt28FwdQ8Kt2tbxFzPgC0DoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==", - "pQABARn//wKhAFggQeUPM119c+6zRsEupA8zshTfrZiLpXx1Ji0UMMumq9IDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==" - ] +getPrekeys :: (Crypto.MonadRandom m) => Word16 -> m [Value] +getPrekeys n = mapM mkPrekey [0 .. n] -render :: [Word16] -> [String] -> [Value] -render is = zipWith (\i k -> object [fromString "id" .= i, fromString "key" .= k]) is +getLastPrekey :: (Crypto.MonadRandom m) => m Value +getLastPrekey = mkPrekey maxBound -somePrekeysRendered :: [Value] -somePrekeysRendered = render [1 ..] somePrekeys +mkPrekey :: (Crypto.MonadRandom m) => Word16 -> m Value +mkPrekey prekeyId = do + pk <- newPrekey prekeyId + pure $ object [fromString "id" .= prekeyId, fromString "key" .= pk] -someLastPrekeysRendered :: [Value] -someLastPrekeysRendered = render (repeat maxBound) someLastPrekeys +-- | https://github.com/wireapp/proteus/blob/bb759d762bfde376fa5a8a08b1d1153a345ab28a/src/internal/keys.rs#L305 +newPrekey :: (Crypto.MonadRandom m) => Word16 -> m String +newPrekey prekeyId = do + secretKey <- Ed25519.generateSecretKey + let publicKey = Ed25519.toPublic secretKey + identitySecretKey <- Ed25519.generateSecretKey + let identityPublicKey = Ed25519.toPublic identitySecretKey + encodePublicKey k = CBOR.encodeMapLen 1 <> CBOR.encodeWord8 0 <> CBOR.encodeBytes (ByteArray.convert k) + encodedIdentityKey = CBOR.encodeMapLen 1 <> CBOR.encodeWord8 0 <> encodePublicKey identityPublicKey + cbor = + CBOR.toStrictByteString $ + CBOR.encodeMapLen 5 + <> (CBOR.encodeWord8 0 <> CBOR.encodeWord8 1) + <> (CBOR.encodeWord8 1 <> CBOR.encodeWord16 prekeyId) + <> (CBOR.encodeWord8 2 <> encodePublicKey publicKey) + <> (CBOR.encodeWord8 3 <> encodedIdentityKey) + <> (CBOR.encodeWord8 4 <> CBOR.encodeNull) + pure . cs $ Base64.encode cbor diff --git a/integration/test/Testlib/Types.hs b/integration/test/Testlib/Types.hs index 9917f10ec58..cdb3d60caa1 100644 --- a/integration/test/Testlib/Types.hs +++ b/integration/test/Testlib/Types.hs @@ -264,8 +264,6 @@ data Env = Env apiVersionByDomain :: Map String Int, manager :: HTTP.Manager, servicesCwdBase :: Maybe FilePath, - prekeys :: IORef [(Int, String)], - lastPrekeys :: IORef [String], mls :: IORef MLSState, resourcePool :: ResourcePool BackendResource, rabbitMQConfig :: RabbitMqAdminOpts, From b1129ba2a06dbe23f33b63f38a4366087da46bde Mon Sep 17 00:00:00 2001 From: Leonhardt Wille Date: Mon, 3 Aug 2026 15:29:32 +0200 Subject: [PATCH 056/113] chore(sftd_disco): move sftd_disco helper to wire-avs-service (#5056) related to WPB-23564 --- changelog.d/5-internal/remove-sftd-disco | 1 + tools/sftd_disco/Dockerfile | 7 --- tools/sftd_disco/Makefile | 6 -- tools/sftd_disco/README.md | 5 -- tools/sftd_disco/sftd_disco.sh | 76 ------------------------ 5 files changed, 1 insertion(+), 94 deletions(-) create mode 100644 changelog.d/5-internal/remove-sftd-disco delete mode 100644 tools/sftd_disco/Dockerfile delete mode 100644 tools/sftd_disco/Makefile delete mode 100644 tools/sftd_disco/README.md delete mode 100755 tools/sftd_disco/sftd_disco.sh diff --git a/changelog.d/5-internal/remove-sftd-disco b/changelog.d/5-internal/remove-sftd-disco new file mode 100644 index 00000000000..0cbcfdde66a --- /dev/null +++ b/changelog.d/5-internal/remove-sftd-disco @@ -0,0 +1 @@ +remove sftd_disco (now lives in wireapp/wire-avs-service) diff --git a/tools/sftd_disco/Dockerfile b/tools/sftd_disco/Dockerfile deleted file mode 100644 index 011aac9c6a1..00000000000 --- a/tools/sftd_disco/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM alpine:3.21.3 - -RUN apk add --no-cache curl bash openssl bind-tools jq - -COPY sftd_disco.sh /usr/bin/sftd_disco.sh - -ENTRYPOINT ["/usr/bin/sftd_disco.sh"] diff --git a/tools/sftd_disco/Makefile b/tools/sftd_disco/Makefile deleted file mode 100644 index 45e71e806c7..00000000000 --- a/tools/sftd_disco/Makefile +++ /dev/null @@ -1,6 +0,0 @@ -.PHONY: docker - -DOCKER_TAG = 1.1.1 - -docker: - docker build -t quay.io/wire/sftd_disco:$(DOCKER_TAG) -f Dockerfile . diff --git a/tools/sftd_disco/README.md b/tools/sftd_disco/README.md deleted file mode 100644 index e78f7679ebe..00000000000 --- a/tools/sftd_disco/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# sftd-disco - -This DISCOvery docker image/bash script converts the result from an SRV DNS lookup of a kubernetes service to a file which can be served by nginx or similar. - -This is useful as a sidecar container to the sftd chart in kubernetes to expose the full list of running sftd servers in cases where sftd runs independently from other backend services. See also [the sftd helm chart](https://github.com/wireapp/wire-server/tree/develop/charts/sftd/) diff --git a/tools/sftd_disco/sftd_disco.sh b/tools/sftd_disco/sftd_disco.sh deleted file mode 100755 index 2cf660cdb3a..00000000000 --- a/tools/sftd_disco/sftd_disco.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash - -set -eo pipefail -exec 2>&1 - -# Assumes /etc/wire/sftd-disco/ directory exists. - -USAGE="example usage: $0 _sft._tcp.wire-server-sftd.wire.svc.cluster.local" -srv_name=${1?$USAGE} - -old="/etc/wire/sftd-disco/sft_servers_all.json" -new="${old}.new" - -function valid_entry() { - # TODO sanity check that this is real dig output - return 0 -} - -function valid_url() { - #TODO basic sanity check - return 0 -} - -# for a given SRV record -# 1. lookup the record -# 2. for each entry: extract host and port and call 'curl host:port/sft/url' -# 4. save the resulting URLs as a json array to a file -# this file can then be served from nginx running besides sft -function upstream() { - local srv_name="$1" - entries=$(dig +short +retries=3 +search SRV "${srv_name}" | sort) - unset servers - comma="" - IFS=$'\t\n' - for entry in $entries; do - if valid_entry "$entry"; then - sft_host_port=$(echo "$entry" | awk '{print $4":"$3}') - sft_url=$(curl -s http://"$sft_host_port"/sft/url | xargs) - if valid_url "$sft_url"; then - servers+=("$comma"'"'"$sft_url"'"') - comma="," - fi - fi - done - # shellcheck disable=SC2128 - if [[ -n "$servers" ]]; then - echo '{"sft_servers_all": ['"${servers[*]}"']}' | jq >${new} - else - printf "" >>${new} - fi -} - -function routing_disco() { - local srv_name="$1" - ivl=$(echo | awk '{ srand(); printf("%f", 2.5 + rand() * 1.5) }') - - [[ -f $old ]] || touch -d "1970-01-01" $old - - echo "" >${new} - upstream "$srv_name" - - diff -q $old $new || { - echo upstream change found, replacing $old with $new - mv $new $old - } - - rm -f $new - - echo done, sleeping "$ivl" - sleep "$ivl" - return 0 -} - -while true; do - routing_disco "$srv_name" -done From 06bed87a71cba798dd2beee73d0d3d0edac92a29 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Mon, 3 Aug 2026 16:11:34 +0200 Subject: [PATCH 057/113] WPB-23631: Move `Galley.Effects.Queue` in `Wire.BoundedQueue` (#5398) --- changelog.d/5-internal/WPB-23631-12 | 1 + libs/wire-subsystems/default.nix | 3 +++ .../wire-subsystems/src/Wire/BoundedQueue.hs | 12 ++++++------ .../wire-subsystems/src/Wire/BoundedQueue/STM.hs | 12 ++++++------ libs/wire-subsystems/wire-subsystems.cabal | 3 +++ services/galley/default.nix | 2 -- services/galley/galley.cabal | 3 --- services/galley/src/Galley/API/Internal.hs | 2 +- services/galley/src/Galley/API/Teams.hs | 14 +++++++------- services/galley/src/Galley/App.hs | 10 +++++----- services/galley/src/Galley/Env.hs | 2 +- services/galley/src/Galley/Run.hs | 2 +- 12 files changed, 34 insertions(+), 32 deletions(-) create mode 100644 changelog.d/5-internal/WPB-23631-12 rename services/galley/src/Galley/Effects/Queue.hs => libs/wire-subsystems/src/Wire/BoundedQueue.hs (84%) rename services/galley/src/Galley/Queue.hs => libs/wire-subsystems/src/Wire/BoundedQueue/STM.hs (89%) diff --git a/changelog.d/5-internal/WPB-23631-12 b/changelog.d/5-internal/WPB-23631-12 new file mode 100644 index 00000000000..ce8678bc538 --- /dev/null +++ b/changelog.d/5-internal/WPB-23631-12 @@ -0,0 +1 @@ +Move `Galley.Effects.Queue` in `Wire.BoundedQueue`. diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix index 38dd6c249bd..5e175b8677d 100644 --- a/libs/wire-subsystems/default.nix +++ b/libs/wire-subsystems/default.nix @@ -116,6 +116,7 @@ , sop-core , ssl-util , statistics +, stm , stomp-queue , string-conversions , tagged @@ -256,6 +257,7 @@ mkDerivation { sop-core ssl-util statistics + stm stomp-queue tagged template @@ -392,6 +394,7 @@ mkDerivation { sop-core ssl-util statistics + stm stomp-queue string-conversions tagged diff --git a/services/galley/src/Galley/Effects/Queue.hs b/libs/wire-subsystems/src/Wire/BoundedQueue.hs similarity index 84% rename from services/galley/src/Galley/Effects/Queue.hs rename to libs/wire-subsystems/src/Wire/BoundedQueue.hs index 66ba5d7bbb4..a51f6eb64a7 100644 --- a/services/galley/src/Galley/Effects/Queue.hs +++ b/libs/wire-subsystems/src/Wire/BoundedQueue.hs @@ -17,8 +17,8 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Galley.Effects.Queue - ( Queue (..), +module Wire.BoundedQueue + ( BoundedQueue (..), tryPush, pop, ) @@ -27,8 +27,8 @@ where import Imports import Polysemy -data Queue a m x where - TryPush :: a -> Queue a m Bool - Pop :: Queue a m a +data BoundedQueue a m x where + TryPush :: a -> BoundedQueue a m Bool + Pop :: BoundedQueue a m a -makeSem ''Queue +makeSem ''BoundedQueue diff --git a/services/galley/src/Galley/Queue.hs b/libs/wire-subsystems/src/Wire/BoundedQueue/STM.hs similarity index 89% rename from services/galley/src/Galley/Queue.hs rename to libs/wire-subsystems/src/Wire/BoundedQueue/STM.hs index de320bde244..3c697a3c0a5 100644 --- a/services/galley/src/Galley/Queue.hs +++ b/libs/wire-subsystems/src/Wire/BoundedQueue/STM.hs @@ -17,21 +17,21 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Galley.Queue +module Wire.BoundedQueue.STM ( Queue, new, tryPush, pop, len, - interpretQueue, + interpretBoundedQueue, ) where import Control.Concurrent.STM qualified as Stm -import Galley.Effects.Queue qualified as E import Imports import Numeric.Natural (Natural) import Polysemy +import Wire.BoundedQueue qualified as E data Queue a = Queue { _len :: Stm.TVar Word, @@ -57,11 +57,11 @@ pop q = liftIO . atomically $ do len :: (MonadIO m) => Queue a -> m Word len q = liftIO $ Stm.readTVarIO (_len q) -interpretQueue :: +interpretBoundedQueue :: (Member (Embed IO) r) => Queue a -> - Sem (E.Queue a ': r) x -> + Sem (E.BoundedQueue a ': r) x -> Sem r x -interpretQueue q = interpret $ \case +interpretBoundedQueue q = interpret $ \case E.TryPush a -> embed @IO $ tryPush q a E.Pop -> embed @IO $ pop q diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index dfa99c46f69..cbdd6929d7b 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -184,6 +184,7 @@ common common-all , sop-core , ssl-util , statistics + , stm , stomp-queue , tagged , template @@ -239,6 +240,8 @@ library Wire.BackgroundJobsRunner.Interpreter Wire.BlockListStore Wire.BlockListStore.Cassandra + Wire.BoundedQueue + Wire.BoundedQueue.STM Wire.BrigAPIAccess Wire.BrigAPIAccess.Rpc Wire.ClientStore diff --git a/services/galley/default.nix b/services/galley/default.nix index 949e941b3a9..82838a7098e 100644 --- a/services/galley/default.nix +++ b/services/galley/default.nix @@ -78,7 +78,6 @@ , sop-core , split , ssl-util -, stm , streaming-commons , string-conversions , tagged @@ -164,7 +163,6 @@ mkDerivation { singletons split ssl-util - stm text time tinylog diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index f7c48e3bc00..c6afea65d94 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -98,12 +98,10 @@ library Galley.API.Teams.Notifications Galley.App Galley.Cassandra - Galley.Effects.Queue Galley.Env Galley.External.LegalHoldService Galley.External.LegalHoldService.Internal Galley.Monad - Galley.Queue Galley.Run Galley.Schema.Run Galley.Schema.V100_OutOfSync @@ -239,7 +237,6 @@ library , singletons , split >=0.2 , ssl-util >=0.1 - , stm >=2.4 , text >=0.11 , time >=1.4 , tinylog >=0.10 diff --git a/services/galley/src/Galley/API/Internal.hs b/services/galley/src/Galley/API/Internal.hs index 0cb715af924..c4bb83e74e9 100644 --- a/services/galley/src/Galley/API/Internal.hs +++ b/services/galley/src/Galley/API/Internal.hs @@ -44,7 +44,6 @@ import Galley.API.Teams qualified as Teams import Galley.API.Teams.Features import Galley.App import Galley.Monad -import Galley.Queue qualified as Q import Galley.Types.Clients import Galley.Types.Error import Imports hiding (head) @@ -78,6 +77,7 @@ import Wire.API.Team.LegalHold (UserLegalHoldStatusEntry (..)) import Wire.API.User (UserIds (cUsers)) import Wire.API.User.Client import Wire.BackendNotificationQueueAccess +import Wire.BoundedQueue.STM qualified as Q import Wire.BrigAPIAccess (BrigAPIAccess) import Wire.ConversationStore hiding (getConversations) import Wire.ConversationStore qualified as ConversationStore diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index 7ef65fa3cda..181a720ae8f 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -77,7 +77,6 @@ import Data.Time.Clock (UTCTime) import Galley.API.LegalHold.Team import Galley.API.Teams.Notifications qualified as APITeamQueue import Galley.App -import Galley.Effects.Queue qualified as E import Galley.Types.Error as Galley import Imports hiding (forkIO) import Polysemy @@ -112,6 +111,7 @@ import Wire.API.Team.SearchVisibility qualified as Public import Wire.API.Team.Size import Wire.API.User qualified as U import Wire.API.User.Search +import Wire.BoundedQueue qualified as E import Wire.BrigAPIAccess import Wire.BrigAPIAccess qualified as Brig import Wire.BrigAPIAccess qualified as E @@ -141,7 +141,7 @@ import Wire.Util getTeamH :: forall r. ( Member (ErrorS 'TeamNotFound) r, - Member (E.Queue DeleteItem) r, + Member (E.BoundedQueue DeleteItem) r, Member TeamStore r, Member TeamSubsystem r ) => @@ -187,7 +187,7 @@ getTeamNameInternal = fmap (fmap TeamName) . E.getTeamName -- one.) getManyTeams :: ( Member TeamStore r, - Member (E.Queue DeleteItem) r, + Member (E.BoundedQueue DeleteItem) r, Member (ListItems LegacyPaging TeamId) r, Member TeamSubsystem r ) => @@ -200,7 +200,7 @@ getManyTeams zusr = lookupTeam :: ( Member TeamStore r, - Member (E.Queue DeleteItem) r, + Member (E.BoundedQueue DeleteItem) r, Member TeamSubsystem r ) => UserId -> @@ -329,7 +329,7 @@ deleteTeam :: Member (ErrorS 'TeamNotFound) r, Member (ErrorS OperationDenied) r, Member (Error AuthenticationError) r, - Member (E.Queue DeleteItem) r, + Member (E.BoundedQueue DeleteItem) r, Member (ErrorS NotATeamMember) r, Member TeamStore r, Member TeamSubsystem r, @@ -361,7 +361,7 @@ internalDeleteBindingTeam :: Member (ErrorS 'TeamNotFound) r, Member (ErrorS 'NotAOneMemberTeam) r, Member (ErrorS 'DeleteQueueFull) r, - Member (E.Queue DeleteItem) r, + Member (E.BoundedQueue DeleteItem) r, Member TeamStore r, Member TeamSubsystem r ) => @@ -1205,7 +1205,7 @@ userIsTeamOwner tid uid = do -- Queues a team for async deletion queueTeamDeletion :: ( Member (ErrorS 'DeleteQueueFull) r, - Member (E.Queue DeleteItem) r + Member (E.BoundedQueue DeleteItem) r ) => TeamId -> UserId -> diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index 1fec2aa4939..bd0131b4d2d 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -54,12 +54,9 @@ import Data.Misc import Data.Qualified import Data.Range import Data.Text qualified as Text -import Galley.Effects.Queue qualified as GE import Galley.Env import Galley.External.LegalHoldService.Internal qualified as LHInternal import Galley.Monad (runApp) -import Galley.Queue -import Galley.Queue qualified as Q import Galley.Types.Error import HTTP2.Client.Manager (Http2Manager, http2ManagerWithSSLCtx) import Hasql.Pool qualified as Hasql @@ -99,6 +96,9 @@ import Wire.API.Team.FeatureFlags import Wire.AWS qualified as Aws import Wire.BackendNotificationQueueAccess (BackendNotificationQueueAccess) import Wire.BackendNotificationQueueAccess.RabbitMq qualified as BackendNotificationQueueAccess +import Wire.BoundedQueue (BoundedQueue) +import Wire.BoundedQueue.STM +import Wire.BoundedQueue.STM qualified as Q import Wire.BrigAPIAccess (BrigAPIAccess) import Wire.BrigAPIAccess.Rpc import Wire.CodeStore (CodeStore) @@ -248,7 +248,7 @@ type GalleyEffects = Input Opts, Input (Either HttpsUrl (Map Domain HttpsUrl)), Now, - GE.Queue DeleteItem, + BoundedQueue DeleteItem, Error Meeting.MeetingError, Error DynError, Error RateLimitExceeded, @@ -507,7 +507,7 @@ evalGalley e = . mapError rateLimitExceededToHttpError . mapError toResponse -- DynError . mapError meetingError - . interpretQueue (e ^. deleteQueue) + . interpretBoundedQueue (e ^. deleteQueue) . nowToIO . runInputConst (e ^. convCodeURI) . runInputConst (e ^. options) diff --git a/services/galley/src/Galley/Env.hs b/services/galley/src/Galley/Env.hs index 24da89559cd..01d18b7a507 100644 --- a/services/galley/src/Galley/Env.hs +++ b/services/galley/src/Galley/Env.hs @@ -49,7 +49,6 @@ import Data.Domain (Domain) import Data.Id import Data.Misc (HttpsUrl) import Data.Time.Clock.DiffTime (millisecondsToDiffTime) -import Galley.Queue qualified as Q import HTTP2.Client.Manager (Http2Manager) import Hasql.Pool.Extended import Imports @@ -61,6 +60,7 @@ import Wire.API.MLS.Keys import Wire.API.Team.FeatureFlags (FanoutLimit) import Wire.API.Team.FeatureFlags qualified as FeatureFlags import Wire.AWS qualified as Aws +import Wire.BoundedQueue.STM qualified as Q import Wire.ExternalAccess.External import Wire.NotificationSubsystem.Interpreter import Wire.Options.Galley diff --git a/services/galley/src/Galley/Run.hs b/services/galley/src/Galley/Run.hs index 7dc8ce40f39..418c285b8df 100644 --- a/services/galley/src/Galley/Run.hs +++ b/services/galley/src/Galley/Run.hs @@ -45,7 +45,6 @@ import Galley.App qualified as App import Galley.Cassandra import Galley.Env import Galley.Monad -import Galley.Queue qualified as Q import Hasql.Pool.Extended (rawPool) import Imports import Network.HTTP.Media.RenderHeader qualified as HTTPMedia @@ -68,6 +67,7 @@ import Wire.API.Routes.Public.Galley import Wire.API.Routes.Version import Wire.API.Routes.Version.Wai import Wire.AWS (awsEnv) +import Wire.BoundedQueue.STM qualified as Q import Wire.JobSubsystem.Migrations (mkArbiterConnectionString, runJobMigrations) import Wire.OpenTelemetry (withTracerC) import Wire.Options.Galley From ca95062bde0a9929dada6f739964fca00654c237 Mon Sep 17 00:00:00 2001 From: Sven Tennie Date: Mon, 3 Aug 2026 17:07:30 +0200 Subject: [PATCH 058/113] multi-ingress: cross-IdP SSO (#5212) Allow users to login with all team IdPs. This is required because there's usually one IdP per ingress in a team and users should be able to login on all ingresses. --- .../2-features/multi-ingress-cross-IdP-SSO | 10 + charts/wire-server/values.yaml | 7 +- .../src/developer/reference/config-options.md | 72 ++- integration/integration.cabal | 1 + integration/test/SetupHelpers.hs | 33 ++ integration/test/Test/Spar/GetByEmail.hs | 24 +- .../test/Test/Spar/MultiIngressCrossIdpSso.hs | 548 ++++++++++++++++++ .../src/Wire/IdPSubsystem/Interpreter.hs | 10 +- .../unit/Wire/IdPSubsystem/InterpreterSpec.hs | 13 +- services/spar/src/Spar/API.hs | 58 +- services/spar/src/Spar/App.hs | 256 ++++++-- services/spar/src/Spar/Error.hs | 2 + .../test-integration/Test/Spar/AppSpec.hs | 2 +- services/spar/test/Test/Spar/Saml/IdPSpec.hs | 4 + 14 files changed, 920 insertions(+), 120 deletions(-) create mode 100644 changelog.d/2-features/multi-ingress-cross-IdP-SSO create mode 100644 integration/test/Test/Spar/MultiIngressCrossIdpSso.hs diff --git a/changelog.d/2-features/multi-ingress-cross-IdP-SSO b/changelog.d/2-features/multi-ingress-cross-IdP-SSO new file mode 100644 index 00000000000..c656b74b546 --- /dev/null +++ b/changelog.d/2-features/multi-ingress-cross-IdP-SSO @@ -0,0 +1,10 @@ +When a team uses multiple SAML IdPs (one per ingress domain) in a multi-ingress +setup, users can now authenticate via any of the team's IdPs even if their +account was originally provisioned under a different one. Spar resolves the +correct account by email-based NameID lookup across all team IdPs and migrates +the user's SSO identity to the authenticating IdP transparently. + +**Important:** Email addresses (`NameID`s) must be unique across configured +IdPs! Otherwise, users may be logged into wrong accounts! + +Please refer to the documentation for further information. diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml index af947278084..ae71d850795 100644 --- a/charts/wire-server/values.yaml +++ b/charts/wire-server/values.yaml @@ -858,13 +858,18 @@ spar: # Optional SHA-1 certificate fingerprint allowlist for IdP descriptor # certificates. Empty list (the default) disables the check. Any non-empty - # list enforces that every cert in an IdP descriptor is present in this + # list enforces that every cert in an IdP descriptor is present in this # list (on create/update and on AuthnResponse). Entries are hex strings; # canonical form is uppercase pairs separated by ':' (e.g. "AA:BB:CC:..."), # but the parser accepts lowercase and omitted separators. # Example: # idpCertFingerprintAllowlist: # - "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD" + # + # WARNING - In a multi-ingress setup, this enables the multi-ingress + # cross-IdP fallback with severe security implications. Refer to the + # documentation of this feature ('Multi-ingress cross-IdP SSO (fallback)') + # to understand what this means. idpCertFingerprintAllowlist: [] logLevel: Info diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index 1cf3a20f1b0..066637dbb07 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -1368,7 +1368,8 @@ Given an email address, the SSO code is looked up by these criteria: - The mapping must be unambiguous (there must be exactly one matching IdP). In multi-ingress mode, IdPs are always bound to one domain; the request domain must match the IdP's configured domain. -- The user was created via SCIM +- The user is a SSO user. So it was created via SCIM with SSO enabled (any IdP + configured in SCIM token) OR created via SSO (no SCIM involved). The last condition ensures that team admins cannot get into locked-out situations due to misconfigured IdPs. @@ -1428,6 +1429,13 @@ All formats must be exactly 40 hex digits (20 bytes). Invalid hex or wrong length (anything not exactly 20 bytes / 40 hex digits) causes the configuration to fail at startup with a clear error message. +!!! danger Multi-ingress cross-IdP SSO fallback + + This feature enables multi-ingress cross-IdP authentication in multi-ingress + scenarios. I.e. multiple IdPs can be used to authenticate the same account. + This can be a security issue if IdPs are not configured for this! See + [Multi-ingress cross-IdP SSO(fallback)](#multi-ingress-cross-idp-sso-fallback). + ### SCIM In Helm: @@ -1651,6 +1659,68 @@ Putting it differently: We require an unambiguous mapping `(team, domain) -> IdP For multi-ingress setups, the [`idpCertFingerprintAllowlist`](#idp-certificate-fingerprint-allowlist) must be configured to restrict which X.509 certificates can be used in IdP metadata. +This restriction was introduced to mitigate risks of +[Multi-ingress cross-IdP SSO (fallback)](#multi-ingress-cross-idp-sso-fallback). + +#### Multi-ingress cross-IdP SSO (fallback) + +Terms used below: + +- _Authenticating IdP_ — the external identity provider that issued the SAML + assertion, identified by the `Issuer` URI inside it. +- _IdP configuration_ — backend's IdP representation registered via + `/identity-providers`, storing the issuer URI, the associated multi-ingress + domain, and the team. + +In the normal SSO flow spar looks up the authenticating user by their `(issuer, +NameID)` pair — matching the assertion's issuer against the IdP configuration +the user was provisioned under. + +In a multi-ingress setup each domain has its own IdP configuration with its own +issuer URI. A user provisioned under domain _A_ has their SSO identity tied to +issuer _A_'s URI. When that user later authenticates via domain _B_, the IdP +authentication response's assertion carries issuer _B_'s URI, so the primary +`(issuer, NameID)` lookup finds nothing. Two IdPs can't have the same Issuer +ID because those must be globally unique, and each external identity provider +controls its own issuer URI (spar can't override it). + +When this primary lookup finds no user, spar therefore attempts a cross-IdP +migration when multi-ingress is configured: + +1. **NameID must be an email address.** Username-based `NameID`s are rejected + to avoid ambiguity across authenticating IdPs. +2. **The matching IdP configuration is resolved.** Spar looks for an IdP + configuration in the team whose issuer URI and configured domain both match + the assertion's issuer and the incoming `Z-Host` header (exact match). + If this condition is unmet, the login is rejected. +3. **Team-wide user search.** The authenticating IdP is now known (step 2), but + the user may still be registered under a *different* team IdP from an + earlier login. Spar therefore searches every IdP configuration in the team, + pairing each one's issuer with the assertion's email NameID, and tries the + primary `(issuer, NameID)` lookup for each pairing until one matches. +4. **Migrate or provision:** + - _Exactly one match found:_ The user's SSO identity is updated to point to + the IdP configuration for the authenticating IdP's issuer, so subsequent + logins hit the primary lookup directly. This saves the complexity of the IdP + configuration lookup and keeps the backend's representations of the user's + SSO data sound. + - _No match found:_ A new user account is auto-provisioned under the + authenticating IdP's configuration. + - _No matching IdP configuration can be resolved:_ Login is rejected. + +##### Security considerations + +It must be ensured that email `NameID`s are unique across IdPs by IdP +administrators. Otherwise, users may be logged into other users' accounts! + +!!! danger + This fallback feature breaks the 1:1 relationship between users and IdPs. To + use it safely, all user accounts must be consistent across all IdPs. Also note, + that this increases the attack surface as you have to secure and maintain + multiple IdPs; while a successful attack on one of them breaks security for all + accounts of a team with that IdP configured. + + **If in doubt, please contact customer support!** ### Webapp diff --git a/integration/integration.cabal b/integration/integration.cabal index 67871bcd465..c36b9c4e730 100644 --- a/integration/integration.cabal +++ b/integration/integration.cabal @@ -211,6 +211,7 @@ library Test.Spar Test.Spar.CertFingerprintAllowlist Test.Spar.GetByEmail + Test.Spar.MultiIngressCrossIdpSso Test.Spar.MultiIngressIdp Test.Spar.MultiIngressSSO Test.Spar.STM diff --git a/integration/test/SetupHelpers.hs b/integration/test/SetupHelpers.hs index c80a942b13d..e36d57efb2b 100644 --- a/integration/test/SetupHelpers.hs +++ b/integration/test/SetupHelpers.hs @@ -31,12 +31,15 @@ import Crypto.Random (getRandomBytes) import Data.Aeson hiding ((.=)) import qualified Data.Aeson as Aeson import qualified Data.ByteString.Base16 as Base16 +import qualified Data.ByteString.Base64 as Base64 import qualified Data.ByteString.Base64.Lazy as EL import qualified Data.ByteString.Base64.URL as B64Url import Data.ByteString.Char8 (unpack) import qualified Data.CaseInsensitive as CI import Data.Default +import Data.Either.Extra (fromRight') import Data.Function +import qualified Data.List.NonEmpty as NonEmpty import Data.String.Conversions (cs) import qualified Data.Text as Text import Data.Text.Encoding (decodeUtf8) @@ -44,6 +47,7 @@ import qualified Data.UUID as UUID import Data.UUID.V1 (nextUUID) import Data.UUID.V4 (nextRandom) import Data.Vector (fromList) +import qualified Data.X509 as X509 import GHC.Stack import qualified SAML2.WebSSO as SAML import qualified SAML2.WebSSO.API.Example as SAML @@ -688,6 +692,35 @@ nextSubject = do 1 -> liftIO $ SAML.mkUNameIDUnspecified . UUID.toText <$> nextRandom either (error . show) pure $ SAML.mkNameID unameId Nothing Nothing Nothing +-- | Generate a random email address and the corresponding email-based `SAML.NameID`. +randomEmailNameId :: (HasCallStack) => App (String, SAML.NameID) +randomEmailNameId = do + email <- randomEmail + let nameId = fromRight (error "could not create name id") $ SAML.emailNameID (Text.pack email) + pure (email, nameId) + +-- | Extract and decode base64 content from HTML error page
 tag.
+--
+-- This is meant to decode SAML errors embedded in HTML pages; e.g. from
+-- @/sso/finalize-login@.
+extractSAMLErrorPageContent :: (HasCallStack) => ByteString -> String
+extractSAMLErrorPageContent body =
+  let bdy = unpack body
+   in case bdy =~ ("
([A-Za-z0-9+/=]+)
" :: String) :: (String, String, String, [String]) of + (_, _, _, [b64Content]) -> cs $ Base64.decodeLenient (cs b64Content) + _ -> error "Could not extract base64 content from
 tag"
+
+-- | Helper to create IdP metadata with a fixed issuer suffix
+makeSampleIdPMetadataWithIssuer ::
+  (HasCallStack) =>
+  (SAML.SignPrivCreds, SAML.SignCreds, X509.SignedCertificate) -> String -> App SampleIdP
+makeSampleIdPMetadataWithIssuer (privcreds, creds, cert) suffix = do
+  let issuerUri = Text.pack $ "https://issuer.net/_" <> suffix
+      requriUri = Text.pack $ "https://requri.net/_req_" <> suffix
+      issuer = SAML.Issuer . fromRight' $ SAML.parseURI' issuerUri
+      requri = fromRight' $ SAML.parseURI' requriUri
+  pure $ SampleIdP (SAML.IdPMetadata issuer requri (cert NonEmpty.:| [])) privcreds creds cert
+
 -- helpers
 
 data ChallengeSetup = ChallengeSetup
diff --git a/integration/test/Test/Spar/GetByEmail.hs b/integration/test/Test/Spar/GetByEmail.hs
index b5c59aab7e5..ec3b8562a52 100644
--- a/integration/test/Test/Spar/GetByEmail.hs
+++ b/integration/test/Test/Spar/GetByEmail.hs
@@ -99,10 +99,10 @@ testGetSsoCodeByEmailWithMultiIngress (TaggedBool requireExternalEmailVerificati
           [] -> assertFailure "Expected at least one email"
 
       let idpTokenConfig = if isIdPScimToken then (def {idp = Just idpIdErnie}) else def
-      scimTok <- createScimToken owner idpTokenConfig
-      scimToken <- scimTok.json %. "token" & asString
+      scimToken <- createScimToken owner idpTokenConfig
+      scimTokenStr <- scimToken.json %. "token" & asString
 
-      createScimUser domain scimToken scimUser >>= assertSuccess
+      createScimUser domain scimTokenStr scimUser >>= assertSuccess
 
       if isIdPScimToken
         then when requireExternalEmailVerification $ do
@@ -157,10 +157,10 @@ testGetSsoCodeByEmailRegular (TaggedBool requireExternalEmailVerification) (Tagg
           [] -> assertFailure "Expected at least one email"
 
       let idpTokenConfig = if isIdPScimToken then (def {idp = Just idpId}) else def
-      scimTok <- createScimToken owner idpTokenConfig
-      scimToken <- scimTok.json %. "token" & asString
+      scimToken <- createScimToken owner idpTokenConfig
+      scimTokenStr <- scimToken.json %. "token" & asString
 
-      createScimUser domain scimToken scimUser >>= assertSuccess
+      createScimUser domain scimTokenStr scimUser >>= assertSuccess
 
       if isIdPScimToken
         then when requireExternalEmailVerification $ do
@@ -222,10 +222,10 @@ testGetSsoCodeByEmailDisabledRegular = do
           (e : _) -> e %. "value" >>= asString
           [] -> assertFailure "Expected at least one email"
 
-      scimTok <- createScimToken owner def {idp = Just idpId}
-      scimToken <- scimTok.json %. "token" & asString
+      scimToken <- createScimToken owner def {idp = Just idpId}
+      scimTokenStr <- scimToken.json %. "token" & asString
 
-      createScimUser domain scimToken scimUser >>= assertSuccess
+      createScimUser domain scimTokenStr scimUser >>= assertSuccess
 
       -- Activate the email so the user can be found by email
       activateEmail domain userEmail
@@ -288,10 +288,10 @@ testGetSsoCodeByEmailDisabledMultiIngress = do
           (e : _) -> e %. "value" >>= asString
           [] -> assertFailure "Expected at least one email"
 
-      scimTok <- createScimToken owner def {idp = Just idpIdErnie}
-      scimToken <- scimTok.json %. "token" & asString
+      scimToken <- createScimToken owner def {idp = Just idpIdErnie}
+      scimTokenStr <- scimToken.json %. "token" & asString
 
-      createScimUser domain scimToken scimUser >>= assertSuccess
+      createScimUser domain scimTokenStr scimUser >>= assertSuccess
 
       -- Activate the email so the user can be found by email
       activateEmail domain userEmail
diff --git a/integration/test/Test/Spar/MultiIngressCrossIdpSso.hs b/integration/test/Test/Spar/MultiIngressCrossIdpSso.hs
new file mode 100644
index 00000000000..faa4b462d65
--- /dev/null
+++ b/integration/test/Test/Spar/MultiIngressCrossIdpSso.hs
@@ -0,0 +1,548 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Test.Spar.MultiIngressCrossIdpSso where
+
+import API.BrigInternal (getUsersId)
+import API.Common (randomHandle)
+import API.Spar
+  ( CreateScimToken (..),
+    createIdpWithZHostV2,
+    createScimToken,
+    createScimUser,
+    finalizeSamlLoginWithZHost,
+    getSPMetadataWithZHost,
+    getSsoCodeByEmailWithZHost,
+    initiateSamlLoginWithZHostAndLabel,
+  )
+import Control.Lens ((.~), (^.))
+import Data.ByteString.Char8 (unpack)
+import Data.Either.Extra
+import Data.String.Conversions (cs)
+import Data.Text (pack)
+import qualified Data.UUID as UUID
+import qualified Data.X509 as X509
+import GHC.Stack
+import qualified SAML2.WebSSO as SAML
+import qualified SAML2.WebSSO.Test.MockResponse as SAML
+import SAML2.WebSSO.Test.Util
+import SetupHelpers
+import Testlib.Certs (fingerprintHex)
+import Testlib.Prelude
+import qualified Text.XML.DSig as SAML
+
+ernieDomain, bertDomain, ernieZHost, bertZHost :: String
+ernieDomain = "ernie.example.com"
+bertDomain = "bert.example.com"
+ernieZHost = "nginz-https." <> ernieDomain
+bertZHost = "nginz-https." <> bertDomain
+
+-- | Test that - in a multi-ingress scenario -  a user provisioned under one
+-- IdP can log in via another IdP, with their SSO identity migrating to the new
+-- IdP transparently.
+--
+-- Covers both SCIM-provisioned and auto-provisioned users, and verifies
+-- back-and-forth migration between IdPs.
+testCrossIdpSsoMigration :: (HasCallStack) => TaggedBool "useScim" -> App ()
+testCrossIdpSsoMigration (TaggedBool useSCIM) = do
+  ernieCredsWithCert@(_, _, ernieCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+  bertCredsWithCert@(_, _, bertCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+
+  withMultiIngressBackend [ernieDomain, bertDomain] [ernieCert, bertCert] $ \domain -> do
+    (owner, tid, _) <- createTeam domain 1
+
+    -- Register IdP for Ernie's domain
+    SampleIdP idpMetaErnie pCredsErnie _ _ <- makeSampleIdPMetadataWithIssuer ernieCredsWithCert "ernie"
+    idpErnie <- createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie
+    idpIdErnie <- asString $ idpErnie.json %. "id"
+
+    -- Register IdP for Bert's domain
+    SampleIdP idpMetaBert pCredsBert _ _ <- makeSampleIdPMetadataWithIssuer bertCredsWithCert "bert"
+    idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert
+    idpIdBert <- asString $ idpBert.json %. "id"
+
+    ernieIssuer <- idpErnie.json %. "metadata.issuer" >>= asString
+    bertIssuer <- idpBert.json %. "metadata.issuer" >>= asString
+
+    (biboEmail, biboNameId) <- randomEmailNameId
+
+    -- Optionally create the user via SCIM (and not automatically)
+    mScimUserId <-
+      if useSCIM
+        then do
+          -- Create SCIM token associated with Ernie's IdP
+          scimToken <- createScimToken owner (def {idp = Just idpIdErnie})
+          scimTokenStr <- scimToken.json %. "token" & asString
+
+          -- Create SCIM user with the email
+          scimUser <- randomScimUserWithEmail biboEmail biboEmail
+          scimUid <- bindResponse (createScimUser domain scimTokenStr scimUser) $ \resp -> do
+            resp.status `shouldMatchInt` 201
+            resp.json %. "id" >>= asString
+
+          activateEmail domain biboEmail
+
+          pure (Just scimUid)
+        else pure Nothing
+
+    -- Bibo logs in on Ernie ingress (should succeed)
+    userIdErnie <-
+      loginWithSamlWithZHost
+        (Just ernieZHost)
+        domain
+        True -- expect success
+        tid
+        biboNameId
+        (idpIdErnie, (idpMetaErnie, pCredsErnie))
+        >>= maybe (error "Expected user ID from SSO login on Ernie domain") pure
+        . fst
+
+    case mScimUserId of
+      Just scimUid ->
+        -- Validate that SCIM-created user matches SSO login user
+        scimUid `shouldMatch` userIdErnie
+      Nothing ->
+        -- Non-SCIM user was auto-provisioned. Activate them.
+        activateEmail domain biboEmail
+
+    -- Verify user's SSO ID has Ernie's issuer (not Bert's)
+    getUsersId domain [userIdErnie] `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoId <- resp.json %. "0.sso_id"
+      ssoIdTenant <- ssoId %. "tenant" >>= asString
+      ssoIdTenant `shouldContain` ernieIssuer
+      ssoIdTenant `shouldNotMatch` bertIssuer
+
+    -- Verify sso/get-by-email returns Ernie's IdP
+    getSsoCodeByEmailWithZHost domain (Just ernieZHost) biboEmail `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoCodeStr <- resp.json %. "sso_code" >>= asString
+      ssoCodeStr `shouldMatch` idpIdErnie
+
+    -- Bibo re-logs in on Ernie (should succeed - proves SSO works on same ingress)
+    (mUserIdErnieAgain, _) <-
+      loginWithSamlWithZHost
+        (Just ernieZHost)
+        domain
+        True -- expect success
+        tid
+        biboNameId
+        (idpIdErnie, (idpMetaErnie, pCredsErnie))
+
+    userIdErnieAgain <- assertJust "Expected user ID from re-login on Ernie domain" mUserIdErnieAgain
+    userIdErnieAgain `shouldMatch` userIdErnie
+
+    -- Same Bibo logs in on Bert ingress with SAME email
+    -- This should SUCCEED because of cross-IdP SSO migration.
+    (mUserIdBert, _) <-
+      loginWithSamlWithZHost
+        (Just bertZHost)
+        domain
+        True -- expect success
+        tid
+        biboNameId
+        (idpIdBert, (idpMetaBert, pCredsBert))
+
+    -- Verify the same user ID is returned (cross-IdP SSO migration worked)
+    userIdBert <- assertJust "Expected user ID from cross-IdP SSO login on Bert domain" mUserIdBert
+    userIdBert `shouldMatch` userIdErnie
+
+    -- Verify user's SSO ID was migrated to Bert's issuer (not Ernie's anymore)
+    getUsersId domain [userIdErnie] `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoId <- resp.json %. "0.sso_id"
+      ssoIdTenant <- ssoId %. "tenant" >>= asString
+      ssoIdTenant `shouldContain` bertIssuer
+      ssoIdTenant `shouldNotMatch` ernieIssuer
+
+    -- Verify sso/get-by-email returns Bert's IdP for Bert's ingress after migration
+    getSsoCodeByEmailWithZHost domain (Just bertZHost) biboEmail `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoCodeStr <- resp.json %. "sso_code" >>= asString
+      ssoCodeStr `shouldMatch` idpIdBert
+
+    -- Verify sso/get-by-email returns Ernie's IdP for Ernie's ingress after migration
+    getSsoCodeByEmailWithZHost domain (Just ernieZHost) biboEmail `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoCodeStr <- resp.json %. "sso_code" >>= asString
+      ssoCodeStr `shouldMatch` idpIdErnie
+
+    -- Login on Ernie again to show back-and-forth migration works
+    (mUserIdErnieFinal, _) <-
+      loginWithSamlWithZHost
+        (Just ernieZHost)
+        domain
+        True -- expect success
+        tid
+        biboNameId
+        (idpIdErnie, (idpMetaErnie, pCredsErnie))
+
+    userIdErnieFinal <- assertJust "Expected user ID from final login on Ernie domain" mUserIdErnieFinal
+    userIdErnieFinal `shouldMatch` userIdErnie
+
+    -- Verify user's SSO ID was migrated back to Ernie's IdP
+    getUsersId domain [userIdErnie] `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoId <- resp.json %. "0.sso_id"
+      ssoIdTenant <- ssoId %. "tenant" >>= asString
+      ssoIdTenant `shouldContain` ernieIssuer
+      ssoIdTenant `shouldNotMatch` bertIssuer
+
+    -- Verify sso/get-by-email returns correct IdP by ingress
+    getSsoCodeByEmailWithZHost domain (Just ernieZHost) biboEmail `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoCodeStr <- resp.json %. "sso_code" >>= asString
+      ssoCodeStr `shouldMatch` idpIdErnie
+
+    getSsoCodeByEmailWithZHost domain (Just bertZHost) biboEmail `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoCodeStr <- resp.json %. "sso_code" >>= asString
+      ssoCodeStr `shouldMatch` idpIdBert
+
+-- | Cross-IdP migration works even when the user's first SSO login is on a different IdP
+-- than the one they were SCIM-provisioned under.
+testScimUserLoginsDifferentIdP :: (HasCallStack) => App ()
+testScimUserLoginsDifferentIdP = do
+  ernieCredsWithCert@(_, _, ernieCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+  bertCredsWithCert@(_, _, bertCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+
+  withMultiIngressBackend [ernieDomain, bertDomain] [ernieCert, bertCert] $ \domain -> do
+    (owner, tid, _) <- createTeam domain 1
+
+    -- Register IdP for Ernie's domain
+    SampleIdP idpMetaErnie pCredsErnie _ _ <- makeSampleIdPMetadataWithIssuer ernieCredsWithCert "ernie"
+    idpErnie <- createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie
+    idpIdErnie <- asString $ idpErnie.json %. "id"
+
+    -- Register IdP for Bert's domain
+    SampleIdP idpMetaBert pCredsBert _ _ <- makeSampleIdPMetadataWithIssuer bertCredsWithCert "bert"
+    idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert
+    idpIdBert <- asString $ idpBert.json %. "id"
+
+    ernieIssuer <- idpErnie.json %. "metadata.issuer" >>= asString
+    bertIssuer <- idpBert.json %. "metadata.issuer" >>= asString
+
+    (biboEmail, biboNameId) <- randomEmailNameId
+
+    -- Provision SCIM user for Ernie's IdP
+    scimToken <- createScimToken owner (def {idp = Just idpIdErnie})
+    scimTokenStr <- scimToken.json %. "token" & asString
+
+    scimUser <- randomScimUserWithEmail biboEmail biboEmail
+    biboUid <- bindResponse (createScimUser domain scimTokenStr scimUser) $ \resp -> do
+      resp.status `shouldMatchInt` 201
+      resp.json %. "id" >>= asString
+
+    activateEmail domain biboEmail
+
+    -- Verify user was created with Ernie's SSO ID
+    getUsersId domain [biboUid] `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoId <- resp.json %. "0.sso_id"
+      ssoIdTenant <- ssoId %. "tenant" >>= asString
+      ssoIdTenant `shouldContain` ernieIssuer
+
+    -- Bibo logs in for the FIRST time on Bert's IdP (NOT Ernie!)
+    -- This tests cross-IdP migration when user has never logged in before (only SCIM provisioned)
+    userIdBert <-
+      loginWithSamlWithZHost
+        (Just bertZHost)
+        domain
+        True -- expect success
+        tid
+        biboNameId
+        (idpIdBert, (idpMetaBert, pCredsBert))
+        >>= maybe (error "Expected user ID from cross-IdP SSO login on Bert domain") pure
+        . fst
+
+    -- Verify the same user ID is returned (cross-IdP SSO migration worked)
+    userIdBert `shouldMatch` biboUid
+
+    -- Verify user's SSO ID was migrated to Bert's issuer
+    getUsersId domain [userIdBert] `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoId <- resp.json %. "0.sso_id"
+      ssoIdTenant <- ssoId %. "tenant" >>= asString
+      ssoIdTenant `shouldContain` bertIssuer
+      ssoIdTenant `shouldNotMatch` ernieIssuer
+
+    -- Login on Ernie to verify back-migration also works
+    (mUserIdErnie, _) <-
+      loginWithSamlWithZHost
+        (Just ernieZHost)
+        domain
+        True -- expect success
+        tid
+        biboNameId
+        (idpIdErnie, (idpMetaErnie, pCredsErnie))
+
+    userIdErnie <- assertJust "Expected user ID from login on Ernie domain" mUserIdErnie
+    userIdErnie `shouldMatch` biboUid
+
+    -- Verify user's SSO ID was migrated back to Ernie's issuer
+    getUsersId domain [biboUid] `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoId <- resp.json %. "0.sso_id"
+      ssoIdTenant <- ssoId %. "tenant" >>= asString
+      ssoIdTenant `shouldContain` ernieIssuer
+      ssoIdTenant `shouldNotMatch` bertIssuer
+
+-- | Login fails when the authenticating IdP's issuer is not registered for the target domain.
+testIdpNotFoundError :: (HasCallStack) => App ()
+testIdpNotFoundError = do
+  ernieCredsWithCert@(_, _, ernieCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+  bertCredsWithCert@(_, _, bertCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+
+  withMultiIngressBackend [ernieDomain, bertDomain] [ernieCert, bertCert] $ \domain -> do
+    (owner, tid, _) <- createTeam domain 1
+
+    -- Register TWO IdPs: one for Ernie domain, one for Bert domain
+    SampleIdP idpMetaErnie pCredsErnie _ _ <- makeSampleIdPMetadataWithIssuer ernieCredsWithCert "ernie"
+    idpErnie <- createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie
+    idpIdErnie <- asString $ idpErnie.json %. "id"
+    ernieIssuer <- idpErnie.json %. "metadata.issuer" >>= asString
+
+    SampleIdP idpMetaBert _ _ _ <- makeSampleIdPMetadataWithIssuer bertCredsWithCert "bert"
+    _idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert
+
+    (_biboEmail, biboNameId) <- randomEmailNameId
+
+    -- Initiate on ernie, then replay the response against bert's endpoint (cross-domain).
+    -- Spar rejects: ernie's issuer is not configured for bert's domain.
+    spmetaBert <- getSPMetadataWithZHost domain (Just bertZHost) tid
+    authnReqRaw <- initiateSamlLoginWithZHostAndLabel domain (Just ernieZHost) Nothing idpIdErnie
+    let spMetaDataBert = fromRight (error "could not decode spmetadata") $ SAML.decode $ cs spmetaBert.body
+        -- Manipulate: redirect the ernie authn request to bert's SP issuer so the audience
+        -- restriction in the SAML response targets bert's ACS URL, not ernie's.
+        parsedAuthnReqErnie =
+          parseAuthnReqResp authnReqRaw.body
+            & SAML.rqIssuer .~ SAML.Issuer (spMetaDataBert ^. SAML.spResponseURL)
+        idpConfigErnie =
+          SAML.IdPConfig
+            (SAML.IdPId (fromMaybe (error "invalid idp id") (UUID.fromString idpIdErnie)))
+            idpMetaErnie
+            ()
+    authnReqResp <-
+      runSimpleSP
+        $ SAML.mkAuthnResponseWithSubj
+          biboNameId
+          pCredsErnie
+          idpConfigErnie
+          spMetaDataBert
+          (Just parsedAuthnReqErnie)
+          True
+
+    bindResponse (finalizeSamlLoginWithZHost domain (Just bertZHost) tid authnReqResp) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+      let bdy = unpack resp.body
+      bdy `shouldContain` "wire:sso:error:"
+      bdy `shouldContain` "\"type\":\"AUTH_ERROR\""
+      bdy `shouldContain` "wire:sso:error:not-found"
+      bdy `shouldContain` "\"label\":\"forbidden\""
+      let expectedErrorMsg =
+            "Could not find IdP: IdP with issuer '"
+              <> ernieIssuer
+              <> "' for domain '"
+              <> bertZHost
+              <> "' is not configured for this team"
+      bdy `shouldContain` expectedErrorMsg
+
+-- | Test that a user of one team cannot log in using the IdP of a different team.
+--
+-- Team B's IdP must not grant access to Team A, even when the SAML response is otherwise
+-- well-formed.
+testCrossTeamIdpLoginRejected :: (HasCallStack) => App ()
+testCrossTeamIdpLoginRejected = do
+  credsA@(_, _, certA) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+  credsB@(_, _, certB) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+
+  withMultiIngressBackend [ernieDomain, bertDomain] [certA, certB] $ \domain -> do
+    -- Team A with IdP A on bert domain
+    (ownerA, tidA, _) <- createTeam domain 1
+    SampleIdP idpMetaA pCredsA _ _ <- makeSampleIdPMetadataWithIssuer credsA "team-a"
+    idpA <- createIdpWithZHostV2 ownerA (Just bertZHost) idpMetaA
+    idpIdA <- asString $ idpA.json %. "id"
+
+    -- Team B with IdP B on ernie domain
+    (ownerB, _, _) <- createTeam domain 1
+    SampleIdP idpMetaB pCredsB _ _ <- makeSampleIdPMetadataWithIssuer credsB "team-b"
+    idpB <- createIdpWithZHostV2 ownerB (Just ernieZHost) idpMetaB
+    idpIdB <- asString $ idpB.json %. "id"
+
+    -- Create Bibo as a user of Team A
+    (biboEmail, biboNameId) <- randomEmailNameId
+    _ <- loginWithSamlWithZHost (Just bertZHost) domain True tidA biboNameId (idpIdA, (idpMetaA, pCredsA))
+    activateEmail domain biboEmail
+
+    -- IdP B lives on ernie and can be initiated there, but finalization fails because IdP B
+    -- belongs to Team B, not Team A.
+    authnReqRespErnie <- buildSamlAuthnResponse domain ernieZHost tidA idpIdB idpMetaB pCredsB biboNameId
+    bindResponse (finalizeSamlLoginWithZHost domain (Just ernieZHost) tidA authnReqRespErnie) $ \resp -> do
+      resp.status `shouldMatchInt` 404
+      extractSAMLErrorPageContent resp.body `shouldContain` "IdpNotFound"
+
+    -- IdP B lives on ernie, not bert: initiation on bert is rejected
+    -- immediately independent of the team (domain mismatch).
+    bindResponse (initiateSamlLoginWithZHostAndLabel domain (Just bertZHost) Nothing idpIdB) $ \resp ->
+      resp.status `shouldMatchInt` 404
+
+-- | Test that non-email NameIDs are rejected in multi-ingress mode.
+--
+-- Multi-ingress cross-IdP SSO requires email-based NameIDs to prevent ambiguities.
+testNonEmailNameIdRejectedInMultiIngress :: (HasCallStack) => App ()
+testNonEmailNameIdRejectedInMultiIngress = do
+  bertCredsWithCert@(_, _, bertCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+
+  withMultiIngressBackend [bertDomain] [bertCert] $ \domain -> do
+    (owner, tid, _) <- createTeam domain 1
+
+    -- Register IdP
+    SampleIdP idpMetaBert pCredsBert _ _ <- makeSampleIdPMetadataWithIssuer bertCredsWithCert "bert"
+    idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert
+    idpIdBert <- asString $ idpBert.json %. "id"
+
+    randomUsername <- randomHandle
+    let usernameNameId =
+          fromRight (error "could not create name id")
+            $ SAML.mkNameID (SAML.mkUNameIDUnspecified (pack randomUsername)) Nothing Nothing Nothing
+
+    authnReqResp <- buildSamlAuthnResponse domain bertZHost tid idpIdBert idpMetaBert pCredsBert usernameNameId
+    bindResponse (finalizeSamlLoginWithZHost domain (Just bertZHost) tid authnReqResp) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+      let bdy = unpack resp.body
+      bdy `shouldContain` "wire:sso:error:multi-ingress-config-error"
+      bdy `shouldContain` "Multi-ingress SSO only supports email-based NameIDs for cross-IdP migration. Username-based NameIDs are not allowed."
+
+-- | Test that SAML responses without a prior authentication request are rejected.
+--
+-- A response referencing a request Spar never stored results in a "bad InResponseTo" error.
+testUnsolicitedSamlResponseRejected :: (HasCallStack) => App ()
+testUnsolicitedSamlResponseRejected = do
+  ernieCredsWithCert@(_, _, ernieCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+  bertCredsWithCert@(_, _, bertCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+
+  withMultiIngressBackend [ernieDomain, bertDomain] [ernieCert, bertCert] $ \domain -> do
+    (owner, tid, _) <- createTeam domain 1
+
+    SampleIdP idpMetaErnie _ _ _ <- makeSampleIdPMetadataWithIssuer ernieCredsWithCert "ernie"
+    void $ createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie
+
+    SampleIdP idpMetaBert pCredsBert _ _ <- makeSampleIdPMetadataWithIssuer bertCredsWithCert "bert"
+    idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert
+    idpIdBert <- asString $ idpBert.json %. "id"
+
+    (_biboEmail, biboNameId) <- randomEmailNameId
+
+    spmeta <- getSPMetadataWithZHost domain (Just bertZHost) tid
+    let spMetaData = fromRight (error "could not decode spmetadata") $ SAML.decode $ cs spmeta.body
+        idpConfig = SAML.IdPConfig (SAML.IdPId (fromMaybe (error "invalid idp id") (UUID.fromString idpIdBert))) idpMetaBert ()
+    -- Create a local authn request (stored in SimpleSP's in-memory store, not in Spar's database)
+    localReq <- runSimpleSP $ SAML.createAuthnRequest 300 (idpMetaBert ^. SAML.edIssuer) (idpMetaBert ^. SAML.edIssuer)
+    authnReqResp <- makeAuthnResponse biboNameId pCredsBert idpConfig spMetaData localReq
+
+    -- Spar cannot find the request (no verdict format stored), so it rejects with server error.
+    -- This is not a user flow, so we can accept any error - even 500 - here.
+    bindResponse (finalizeSamlLoginWithZHost domain (Just bertZHost) tid authnReqResp) $ \resp -> do
+      resp.status `shouldMatchInt` 500
+      resp.json %. "label" `shouldMatch` "server-error"
+
+-- | Test that SAML responses for one ingress are rejected when submitted to a
+-- different ingress.
+--
+-- A login request on the ernie ingress must be finalized on the ernie ingress.
+-- Finalizing on the bert ingress should fail with a bad recipient error.
+testCrossIngressRequestResponseMismatch :: (HasCallStack) => App ()
+testCrossIngressRequestResponseMismatch = do
+  ernieCredsWithCert@(_, _, ernieCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+  bertCredsWithCert@(_, _, bertCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+
+  withMultiIngressBackend [ernieDomain, bertDomain] [ernieCert, bertCert] $ \domain -> do
+    (owner, tid, _) <- createTeam domain 1
+
+    SampleIdP idpMetaErnie pCredsErnie _ _ <- makeSampleIdPMetadataWithIssuer ernieCredsWithCert "ernie"
+    idpErnie <- createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie
+    idpIdErnie <- asString $ idpErnie.json %. "id"
+
+    SampleIdP idpMetaBert _ _ _ <- makeSampleIdPMetadataWithIssuer bertCredsWithCert "bert"
+    void $ createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert
+
+    (_biboEmail, biboNameId) <- randomEmailNameId
+
+    -- The SAML response's Destination is ernie's ACS (Assertion Consumer Service) URL,
+    -- i.e. ernie's /sso/finalize-login endpoint. Submitting it to bert's endpoint causes
+    -- a Destination mismatch ("bad Recipient").
+    authnReqResp <- buildSamlAuthnResponse domain ernieZHost tid idpIdErnie idpMetaErnie pCredsErnie biboNameId
+
+    -- Finalize on bert ingress — Destination mismatch, bad recipient
+    bindResponse (finalizeSamlLoginWithZHost domain (Just bertZHost) tid authnReqResp) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+      let bdy = unpack resp.body
+      bdy `shouldContain` "wire:sso:error:forbidden"
+      bdy `shouldContain` "bad Recipient"
+
+-- | Run a test with the standard multi-ingress backend configuration.
+-- Takes base domain names (e.g. "ernie.example.com"); the ZHost and SSO/webapp URLs
+-- are derived from each base domain.
+-- Optionally accepts IdP certificates to add to the allowlist.
+withMultiIngressBackend :: (HasCallStack) => [String] -> [X509.SignedCertificate] -> (String -> App ()) -> App ()
+withMultiIngressBackend baseDomains certs action =
+  withModifiedBackend
+    def
+      { sparCfg =
+          removeField "saml.spSsoUri"
+            >=> removeField "saml.spAppUri"
+            >=> removeField "saml.contacts"
+            >=> setField "saml.spDomainConfigs" (object (map mkDomainEntry baseDomains))
+            >=> setField "enableIdPByEmailDiscovery" True
+            >=> if null certs
+              then pure
+              else setField "idpCertFingerprintAllowlist" (map fingerprintHex certs),
+        galleyCfg = setField "settings.featureFlags.sso" "enabled-by-default"
+      }
+    action
+  where
+    mkDomainEntry base =
+      ("nginz-https." <> base)
+        .= object
+          [ "spAppUri" .= ("https://webapp." <> base :: String),
+            "spSsoUri" .= ("https://nginz-https." <> base <> "/sso" :: String),
+            "contacts" .= [object ["type" .= ("ContactTechnical" :: String)]]
+          ]
+
+-- | Initiate a SAML login and build a signed authn response for the given NameID.
+-- Use this when testing error cases that require manual control over the finalize step.
+buildSamlAuthnResponse ::
+  (HasCallStack, MakesValue domain) =>
+  domain ->
+  String ->
+  String ->
+  String ->
+  SAML.IdPMetadata ->
+  SAML.SignPrivCreds ->
+  SAML.NameID ->
+  App SAML.SignedAuthnResponse
+buildSamlAuthnResponse domain mbZHost tid idpId idpMeta pcreds nameId = do
+  spmeta <- getSPMetadataWithZHost domain (Just mbZHost) tid
+  authnreq <- initiateSamlLoginWithZHostAndLabel domain (Just mbZHost) Nothing idpId
+  let spMetaData = fromRight (error "could not decode spmetadata") $ SAML.decode $ cs spmeta.body
+      parsedAuthnReq = parseAuthnReqResp authnreq.body
+      idpConfig =
+        SAML.IdPConfig
+          (SAML.IdPId (fromMaybe (error "invalid idp id") (UUID.fromString idpId)))
+          idpMeta
+          ()
+  makeAuthnResponse nameId pcreds idpConfig spMetaData parsedAuthnReq
diff --git a/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs
index 546f5eedef6..2af0da60dcf 100644
--- a/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs
+++ b/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs
@@ -93,7 +93,7 @@ getSsoCodeByEmailImpl enableIdPByEmailDiscovery mbHost email =
         case users of
           [] -> pure Nothing
           [user] -> do
-            if isScimOrSsoUser user
+            if isSsoUser user
               then do
                 mbTeam <- getTeamId (userId user)
                 case mbTeam of
@@ -112,9 +112,11 @@ getSsoCodeByEmailImpl enableIdPByEmailDiscovery mbHost email =
     userIdToText :: Qualified UserId -> Text
     userIdToText uid = idToText (qUnqualified uid) <> "@" <> domainText (qDomain uid)
 
-    isScimOrSsoUser :: User -> Bool
-    isScimOrSsoUser user =
-      userManagedBy user == ManagedByScim && isJust (userSSOId user)
+    -- This used to check if the user is SCIM AND SSO! The RFC ("2025-05-12
+    -- RFC: Default SSO flow for team by host domain") is ambiguous about this.
+    -- The customer currently provisions non-SCIM, so this fits their usecase.
+    isSsoUser :: User -> Bool
+    isSsoUser = isJust . userSSOId
 
     findIdPByDomain :: (Member (Logger (Log.Msg -> Log.Msg)) r) => [IP.IdP] -> Sem r (Maybe SAML.IdPId)
     findIdPByDomain [] = pure Nothing
diff --git a/libs/wire-subsystems/test/unit/Wire/IdPSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/IdPSubsystem/InterpreterSpec.hs
index fd7cf6f7631..11ab3c43f28 100644
--- a/libs/wire-subsystems/test/unit/Wire/IdPSubsystem/InterpreterSpec.hs
+++ b/libs/wire-subsystems/test/unit/Wire/IdPSubsystem/InterpreterSpec.hs
@@ -37,7 +37,6 @@ import System.Logger.Message qualified as Log
 import Test.Hspec
 import Test.Hspec.QuickCheck
 import Test.QuickCheck
-import Test.QuickCheck.Gen
 import Wire.API.Team.Member
 import Wire.API.User
 import Wire.API.User.IdentityProvider
@@ -318,15 +317,9 @@ spec = describe "IdPSubsystem.Interpreter" $ do
       result `shouldBe` Right Nothing
       expectedSevereLogs logs mempty
 
-    prop "returns Nothing for non SCIM/SSO user" $ \(teamMember :: TeamMember) user idp userRef email teamId -> do
-      (userIdentity, userManagedBy) <-
-        generate $
-          ( do
-              ui <- Test.QuickCheck.Gen.elements [Just (SSOIdentity (UserSSOId userRef) (Just email)), Nothing]
-              mngtBy :: ManagedBy <- arbitrary
-              pure (ui, mngtBy)
-          )
-            `suchThat` (\(ui, mngtBy) -> isNothing ui || mngtBy == ManagedByWire)
+    prop "returns Nothing for non SSO user" $ \(teamMember :: TeamMember) user idp email teamId -> do
+      userManagedBy <- generate (arbitrary :: Gen ManagedBy)
+      userIdentity <- generate (oneof [pure Nothing, pure $ Just (EmailIdentity email)])
 
       let userWithEmail =
             user
diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs
index de2749a85e1..7458d0b7384 100644
--- a/services/spar/src/Spar/API.hs
+++ b/services/spar/src/Spar/API.hs
@@ -68,8 +68,6 @@ import qualified Data.List.NonEmpty as NE
 import qualified Data.Map as Map
 import Data.Proxy
 import Data.Range
-import qualified Data.Set as Set
-import qualified Data.Text.Encoding as TE
 import Data.Text.Encoding.Error
 import qualified Data.Text.Lazy as T
 import Data.Text.Lazy.Encoding
@@ -211,6 +209,7 @@ apiSSO ::
     Member VerdictFormatStore r,
     Member AReqIDStore r,
     Member ScimTokenStore r,
+    Member ScimExternalIdStore r,
     Member DefaultSsoCode r,
     Member IdPConfigStore r,
     Member IdPSubsystem r,
@@ -427,6 +426,7 @@ authresp ::
     Member VerdictFormatStore r,
     Member AReqIDStore r,
     Member ScimTokenStore r,
+    Member ScimExternalIdStore r,
     Member IdPConfigStore r,
     Member SAML2 r,
     Member SamlProtocolSettings r,
@@ -457,7 +457,7 @@ authresp mbtid arbody mbHost = do
         SAML.AccessDenied (shouldRedirectToInit -> True) ->
           redirectToInit idp
         _ -> do
-          SAML.ResponseVerdict result <- verdictHandler assertions verdict idp
+          SAML.ResponseVerdict result <- verdictHandler assertions verdict idp mbHost
           throw @SparError $ SAML.CustomServant result
 
     -- Whenever at least one of the denied reasons is `DeniedNoInResponseTo`, try again.
@@ -811,58 +811,6 @@ idpCreateV7 samlConfig tid zUser idpmeta mReplaces mApiversion mHandle = do
         throwSparSem $
           SparProvisioningMoreThanOneIdP ScimTokenAndSecondIdpForbidden
 
--- | Reject IdPs whose cert SHA-1 is not in the configured allowlist.
---
--- Empty/absent allowlist is a no-op in the regular case, it short-circuits to
--- error for multi-ingress setups. I.e. the allowlist is required for
--- multi-ingress setups.
-assertCertsAllowlisted ::
-  ( Member (Input Opts) r,
-    Member (Logger (Msg -> Msg)) r,
-    Member (Error SparError) r
-  ) =>
-  SAML.IdPMetadata ->
-  Sem r ()
-assertCertsAllowlisted idpmeta = do
-  mAllow <- inputs idpCertFingerprintAllowlist
-  samlConfig <- inputs saml
-  let certs = idpmeta ^. SAML.edCertAuthnResponse
-      issuerTxt =
-        TE.decodeUtf8 $
-          URI.serializeURIRef' (idpmeta ^. SAML.edIssuer . SAML.fromIssuer)
-  when (isEmptyAllowList mAllow && SAML.isMultiIngressConfig samlConfig) $ do
-    let fingerprintHex = renderFingerprintHex . certSha1Fingerprint . NE.head $ certs
-    logMultiIngressEmptyAllowlist fingerprintHex issuerTxt
-    throwSparSem (SparIdPCertNotAllowed (T.fromStrict fingerprintHex))
-  case mAllow of
-    Nothing -> pure ()
-    Just (CertFingerprintAllowlist allowed)
-      | Set.null allowed -> pure ()
-      | otherwise -> do
-          forM_ certs $ \c -> do
-            let fingerprint = certSha1Fingerprint c
-                fingerprintHex = renderFingerprintHex fingerprint
-            unless (Set.member fingerprint allowed) $ do
-              logCertNotInAllowlist fingerprintHex issuerTxt
-              throwSparSem (SparIdPCertNotAllowed (T.fromStrict fingerprintHex))
-  where
-    logMultiIngressEmptyAllowlist fingerprintHex issuerTxt =
-      Logger.warn $
-        Log.msg ("Refusing IdP request: multi-ingress enabled and allowlist empty" :: ByteString)
-          . Log.field "fingerprint" fingerprintHex
-          . Log.field "issuer" issuerTxt
-
-    logCertNotInAllowlist fingerprintHex issuerTxt =
-      Logger.warn $
-        Log.msg ("Refusing IdP request: cert fingerprint not in allowlist" :: ByteString)
-          . Log.field "fingerprint" fingerprintHex
-          . Log.field "issuer" issuerTxt
-
-    isEmptyAllowList :: Maybe CertFingerprintAllowlist -> Bool
-    isEmptyAllowList Nothing = True
-    isEmptyAllowList (Just (CertFingerprintAllowlist allowed)) | Set.null allowed = True
-    isEmptyAllowList (Just _) = False
-
 -- | Check that issuer is not used anywhere in the system ('WireIdPAPIV1', here it is a
 -- database key for finding IdPs), or anywhere in this team ('WireIdPAPIV2'), that request
 -- URI is https, that the replacement IdPId, if present, points to our team, and possibly
diff --git a/services/spar/src/Spar/App.hs b/services/spar/src/Spar/App.hs
index b80baf1c0ac..64e401b88e2 100644
--- a/services/spar/src/Spar/App.hs
+++ b/services/spar/src/Spar/App.hs
@@ -30,6 +30,7 @@ module Spar.App
     validateEmail,
     errorPage,
     deleteTeam,
+    assertCertsAllowlisted,
     sparToServerErrorWithLogging,
     renderSparErrorWithLogging,
   )
@@ -39,26 +40,33 @@ import Bilge
 import qualified Cassandra as Cas
 import Control.Exception (assert)
 import Control.Lens hiding ((.=))
+import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT)
 import Data.Aeson as Aeson (encode, object, (.=))
 import Data.Aeson.Text as Aeson (encodeToLazyText)
 import Data.ByteString (toStrict)
 import qualified Data.ByteString.Builder as Builder
 import qualified Data.ByteString.UTF8 as UTF8
 import qualified Data.CaseInsensitive as CI
+import Data.Domain
 import Data.Id
 import Data.List.NonEmpty (NonEmpty ((:|)))
 import qualified Data.List.NonEmpty as NonEmpty
+import qualified Data.Set as Set
 import qualified Data.Text as Text
 import Data.Text.Ascii (encodeBase64, toText)
+import qualified Data.Text.Encoding as TE
 import qualified Data.Text.Encoding as Text
 import qualified Data.Text.Lazy as LText
 import qualified Data.Text.Lazy.Encoding as LText
 import Data.These
+import qualified Data.X509 as X509
+import Data.X509.Extended
 import Imports hiding (MonadReader, asks, log)
 import qualified Network.HTTP.Types.Status as Http
 import qualified Network.Wai.Utilities.Error as Wai
 import Polysemy
 import Polysemy.Error
+import Polysemy.Input
 import SAML2.Util (renderURI)
 import SAML2.WebSSO
   ( Issuer (..),
@@ -83,6 +91,8 @@ import Spar.Sem.ScimTokenStore (ScimTokenStore)
 import qualified Spar.Sem.ScimTokenStore as ScimTokenStore
 import Spar.Sem.VerdictFormatStore (VerdictFormatStore)
 import qualified Spar.Sem.VerdictFormatStore as VerdictFormatStore
+import System.Logger (Msg)
+import qualified System.Logger as Log
 import qualified System.Logger as TinyLog
 import URI.ByteString as URI
 import Web.Cookie (SetCookie, renderSetCookie)
@@ -276,26 +286,29 @@ validateEmail _ _ _ = pure ()
 verdictHandler ::
   (HasCallStack) =>
   ( Member Random r,
-    Member (Logger String) r,
+    Member (Logger (Msg -> Msg)) r,
     Member GalleyAPIAccess r,
     Member BrigAPIAccess r,
     Member AReqIDStore r,
     Member VerdictFormatStore r,
     Member ScimTokenStore r,
+    Member ScimExternalIdStore r,
     Member IdPConfigStore r,
     Member (Error SparError) r,
     Member Reporter r,
-    Member SAMLUserStore r
+    Member SAMLUserStore r,
+    Member (Input Opts) r
   ) =>
   NonEmpty SAML.Assertion ->
   SAML.AccessVerdict ->
   IdP ->
+  Maybe Domain ->
   Sem r SAML.ResponseVerdict
-verdictHandler aresp verdict idp = do
+verdictHandler aresp verdict idp mbHost = do
   -- [3/4.1.4.2]
   --  [...] If the containing message is in response to an , then
   -- the InResponseTo attribute MUST match the request's ID.
-  Logger.log Logger.Debug $ "entering verdictHandler: " <> show (aresp, verdict)
+  Logger.debug $ Log.msg ("entering verdictHandler" :: String) . Log.field "aresp" (show aresp) . Log.field "verdict" (show verdict)
   reqid <- do
     let xs = SAML.assertionToInResponseTo `mapM` aresp
     case NonEmpty.nub <$> xs of
@@ -305,13 +318,13 @@ verdictHandler aresp verdict idp = do
   format :: Maybe VerdictFormat <- VerdictFormatStore.get reqid
   resp <- case format of
     Just (VerdictFormatWeb mlabel) ->
-      verdictHandlerResult verdict idp mlabel >>= verdictHandlerWeb
+      verdictHandlerResult verdict idp mlabel mbHost >>= verdictHandlerWeb
     Just (VerdictFormatMobile granted denied mlabel) ->
-      verdictHandlerResult verdict idp mlabel >>= verdictHandlerMobile granted denied
+      verdictHandlerResult verdict idp mlabel mbHost >>= verdictHandlerMobile granted denied
     Nothing ->
       -- (this shouldn't happen too often, see 'storeVerdictFormat')
       throwSparSem SparNoSuchRequest
-  Logger.log Logger.Debug $ "leaving verdictHandler: " <> show resp
+  Logger.debug $ Log.msg ("leaving verdictHandler" :: String) . Log.field "resp" (show resp)
   pure resp
 
 data VerdictHandlerResult
@@ -323,23 +336,26 @@ data VerdictHandlerResult
 verdictHandlerResult ::
   (HasCallStack) =>
   ( Member Random r,
-    Member (Logger String) r,
+    Member (Logger (Msg -> Msg)) r,
     Member GalleyAPIAccess r,
     Member BrigAPIAccess r,
     Member ScimTokenStore r,
+    Member ScimExternalIdStore r,
     Member IdPConfigStore r,
     Member (Error SparError) r,
     Member Reporter r,
-    Member SAMLUserStore r
+    Member SAMLUserStore r,
+    Member (Input Opts) r
   ) =>
   SAML.AccessVerdict ->
   IdP ->
   Maybe CookieLabel ->
+  Maybe Domain ->
   Sem r VerdictHandlerResult
-verdictHandlerResult verdict idp mlabel = do
-  Logger.log Logger.Debug $ "entering verdictHandlerResult"
-  result <- catchVerdictErrors $ verdictHandlerResultCore idp verdict mlabel
-  Logger.log Logger.Debug $ "leaving verdictHandlerResult" <> show result
+verdictHandlerResult verdict idp mlabel mbHost = do
+  Logger.debug $ Log.msg ("entering verdictHandlerResult" :: String)
+  result <- catchVerdictErrors $ verdictHandlerResultCore idp verdict mlabel mbHost
+  Logger.debug $ Log.msg ("leaving verdictHandlerResult" :: String) . Log.field "result" (show result)
   pure result
 
 catchVerdictErrors ::
@@ -399,48 +415,154 @@ moveUserToNewIssuer oldUserRef newUserRef uid = do
   SAMLUserStore.delete uid oldUserRef
 
 verdictHandlerResultCore ::
+  forall r.
   (HasCallStack) =>
   ( Member Random r,
-    Member (Logger String) r,
+    Member (Logger (Msg -> Msg)) r,
     Member GalleyAPIAccess r,
     Member BrigAPIAccess r,
     Member ScimTokenStore r,
+    Member ScimExternalIdStore r,
     Member IdPConfigStore r,
     Member (Error SparError) r,
-    Member SAMLUserStore r
+    Member SAMLUserStore r,
+    Member (Input Opts) r
   ) =>
   IdP ->
   SAML.AccessVerdict ->
   Maybe CookieLabel ->
+  Maybe Domain ->
   Sem r VerdictHandlerResult
-verdictHandlerResultCore idp verdict mlabel = case verdict of
+verdictHandlerResultCore idp verdict mlabel mbHost = case verdict of
   SAML.AccessDenied reasons -> do
     pure $ VerifyHandlerDenied reasons
   SAML.AccessGranted uref -> do
     uid :: UserId <- do
       let team' = idp ^. idpExtraInfo . team
-          err = SparUserRefInNoOrMultipleTeams . LText.pack . show $ uref
-      getUserByUrefUnsafe uref >>= \case
-        Just usr -> do
-          if userTeam usr == Just team'
-            then pure (userId usr)
-            else throwSparSem err
-        Nothing -> do
-          getUserByUrefViaOldIssuerUnsafe idp uref >>= \case
-            Just (olduref, usr) -> do
-              let uid = userId usr
-              if userTeam usr == Just team'
-                then moveUserToNewIssuer olduref uref uid >> pure uid
-                else throwSparSem err
-            Nothing -> do
-              buid <- Id <$> Random.uuid
-              autoprovisionSamlUser idp buid uref
-              validateSamlEmailIfExists buid uref
-              pure buid
-
-    Logger.log Logger.Debug ("granting sso login for " <> show uid)
+      samlConfig <- input <&> (.saml)
+      findUserWithUref idp team' uref >>= \case
+        Just uid -> pure uid
+        Nothing ->
+          if SAML.isMultiIngressConfig samlConfig
+            then multiIngressFlow team'
+            else provisionNewUser
+    Logger.debug $ Log.msg ("granting sso login" :: String) . Log.field "user" (idToText uid)
     cky <- BrigAPIAccess.ssoLogin uid mlabel
     pure $ VerifyHandlerGranted cky uid
+    where
+      provisionNewUser :: Sem r UserId
+      provisionNewUser = do
+        buid <- Id <$> Random.uuid
+        autoprovisionSamlUser idp buid uref
+        validateSamlEmailIfExists buid uref
+        pure buid
+
+      -- Try to find a user by UserRef, with fallback to old issuers. Returns
+      -- the UserId if found and in the correct team, Nothing if not found.
+      -- Throws SparUserRefInNoOrMultipleTeams if user is found but in the
+      -- wrong team. Side effect: Old-style users (found via old issuers) are
+      -- migrated to the new issuer.
+      findUserWithUref :: IdP -> TeamId -> SAML.UserRef -> Sem r (Maybe UserId)
+      findUserWithUref idp' team'' uref' = do
+        let err = SparUserRefInNoOrMultipleTeams . LText.pack . show $ uref'
+        getUserByUrefUnsafe uref' >>= \case
+          Just usr -> do
+            if userTeam usr == Just team''
+              then pure (Just (userId usr))
+              else throwSparSem err
+          Nothing -> do
+            getUserByUrefViaOldIssuerUnsafe idp' uref' >>= \case
+              Just (olduref, usr) -> do
+                let uid = userId usr
+                if userTeam usr == Just team''
+                  then moveUserToNewIssuer olduref uref' uid >> pure (Just uid)
+                  else throwSparSem err
+              Nothing -> pure Nothing
+
+      -- In multi-ingress scenarios users can be already assigned to one IdP,
+      -- but try to authenticate with another. This happens when users switch
+      -- the used domain as IdPs are domain-bound. We allow this, when the new
+      -- IdP is configured for the user's team and the used domain.
+      -- Additionally, the provided NameId must be an email address (no
+      -- username) to prevent ambiguities (though, we know this won't be
+      -- guarding against all ambiguity cases).
+      -- When we've found the matching IdP and the user's old one, we migrate
+      -- the user to the new one to not have to run this search again when the
+      -- user logs in with this IdP.
+      multiIngressFlow :: TeamId -> Sem r UserId
+      multiIngressFlow team' =
+        case uref of
+          SAML.UserRef _ (view SAML.nameID -> UNameIDEmail _) -> do
+            teamIdPs <- IdPConfigStore.getConfigsByTeam team'
+            let urefIssuer = uref ^. SAML.uidTenant
+
+            case selectAuthenticatingIdP teamIdPs urefIssuer mbHost of
+              Nothing -> do
+                let issuerText = urefIssuer ^. SAML.fromIssuer . to (TE.decodeUtf8 . URI.serializeURIRef')
+                    domainAsText = maybe "default" domainText mbHost
+                    errorMsg =
+                      "IdP with issuer '"
+                        <> issuerText
+                        <> "' for domain '"
+                        <> domainAsText
+                        <> "' is not configured for this team"
+                throwSparSem $ SparIdPNotFound (LText.fromStrict errorMsg)
+              Just multiIngressIdp -> do
+                assertCertsAllowlisted (multiIngressIdp ^. SAML.idpMetadata)
+                let subject = uref ^. SAML.uidSubject
+                findUserInTeamIdPs team' subject teamIdPs >>= \case
+                  Nothing -> do
+                    logMultiIngressProvisioningNewUser idp uref multiIngressIdp mbHost
+                    provisionNewUser
+                  Just (uid, oldUref) -> do
+                    logMultiIngressMigratingUser idp uid oldUref uref multiIngressIdp mbHost
+                    moveUserToNewIssuer oldUref uref uid
+                    pure uid
+          _userRef ->
+            throwSparSem . SparMultiIngressIdPConfiguration $
+              "Multi-ingress SSO only supports email-based NameIDs for cross-IdP migration. "
+                <> "Username-based NameIDs are not allowed."
+
+      -- Try to authenticate against all IdPs. In case, return the UserId and the old UserRef.
+      findUserInTeamIdPs :: TeamId -> SAML.NameID -> [IdP] -> Sem r (Maybe (UserId, SAML.UserRef))
+      findUserInTeamIdPs team'' subject idps = runMaybeT $ asum $ map tryIdP idps
+        where
+          tryIdP :: IdP -> MaybeT (Sem r) (UserId, SAML.UserRef)
+          tryIdP idp' = do
+            let oldIssuer = idp' ^. SAML.idpMetadata . SAML.edIssuer
+                oldUref = SAML.UserRef oldIssuer subject
+            uid <- MaybeT $ findUserWithUref idp' team'' oldUref
+            pure (uid, oldUref)
+
+      selectAuthenticatingIdP :: [IdP] -> Issuer -> Maybe Domain -> Maybe IdP
+      selectAuthenticatingIdP teamIdPs issuer mbDomain =
+        find matchesIssuerAndDomain teamIdPs
+        where
+          matchesIssuerAndDomain idp' =
+            idp' ^. SAML.idpMetadata . SAML.edIssuer == issuer
+              && idp' ^. idpExtraInfo . domain == mbDomain
+
+      logMultiIngressProvisioningNewUser :: IdP -> SAML.UserRef -> IdP -> Maybe Domain -> Sem r ()
+      logMultiIngressProvisioningNewUser idp' uref' multiIngressIdp' mbHost' =
+        Logger.info $
+          Log.msg ("Multi-ingress SSO: IdP found but user does not exist, provisioning new user" :: String)
+            . Log.field "team" (idToText (idp' ^. idpExtraInfo . team))
+            . Log.field "issuer" (uref' ^. SAML.uidTenant . SAML.fromIssuer . to URI.serializeURIRef')
+            . Log.field "multi_ingress_idp" (multiIngressIdp' ^. SAML.idpId . to SAML.fromIdPId . to show)
+            . Log.field "authenticating_idp" (idp' ^. SAML.idpId . to SAML.fromIdPId . to show)
+            . Log.field "domain" (mbHost' & maybe "None" domainText)
+
+      logMultiIngressMigratingUser :: IdP -> UserId -> SAML.UserRef -> SAML.UserRef -> IdP -> Maybe Domain -> Sem r ()
+      logMultiIngressMigratingUser idp' uid' oldUref' uref' multiIngressIdp' mbHost' =
+        Logger.info $
+          Log.msg ("Multi-ingress SSO: user found via different IdP, migrating issuer" :: String)
+            . Log.field "team" (idToText (idp' ^. idpExtraInfo . team))
+            . Log.field "user" (idToText uid')
+            . Log.field "old_issuer" (oldUref' ^. SAML.uidTenant . SAML.fromIssuer . to URI.serializeURIRef')
+            . Log.field "new_issuer" (uref' ^. SAML.uidTenant . SAML.fromIssuer . to URI.serializeURIRef')
+            . Log.field "authenticating_idp" (idp' ^. SAML.idpId . to SAML.fromIdPId . to show)
+            . Log.field "multi_ingress_idp" (multiIngressIdp' ^. SAML.idpId . to SAML.fromIdPId . to show)
+            . Log.field "domain" (mbHost' & maybe "None" domainText)
 
 -- | If the client is web, it will be served with an HTML page that it can process to decide whether
 -- to log the user in or show an error.
@@ -614,6 +736,68 @@ deleteTeam team' = do
     SAMLUserStore.deleteByIssuer issuer
     IdPConfigStore.deleteConfig idp
 
+-- | Reject IdPs whose cert SHA-1 is not in the configured allowlist.
+--
+-- Empty/absent allowlist is a no-op in the regular case, it short-circuits to
+-- error for multi-ingress setups. I.e. the allowlist is required for
+-- multi-ingress setups.
+assertCertsAllowlisted ::
+  forall r.
+  ( Member (Input Opts) r,
+    Member (Logger (Msg -> Msg)) r,
+    Member (Error SparError) r
+  ) =>
+  SAML.IdPMetadata ->
+  Sem r ()
+assertCertsAllowlisted idpmeta = do
+  mAllow <- inputs idpCertFingerprintAllowlist
+  let certs = idpmeta ^. SAML.edCertAuthnResponse
+      issuerTxt =
+        TE.decodeUtf8 $
+          URI.serializeURIRef' (idpmeta ^. SAML.edIssuer . SAML.fromIssuer)
+  guardMultiIngressCertsAllowlistNotEmpty mAllow certs issuerTxt
+  case mAllow of
+    Nothing -> pure ()
+    Just (CertFingerprintAllowlist allowed)
+      | Set.null allowed -> pure ()
+      | otherwise -> do
+          forM_ certs $ \c -> do
+            let fingerprint = certSha1Fingerprint c
+                fingerprintHex = renderFingerprintHex fingerprint
+            unless (Set.member fingerprint allowed) $ do
+              logCertNotInAllowlist fingerprintHex issuerTxt
+              throwSparSem (SparIdPCertNotAllowed (LText.fromStrict fingerprintHex))
+  where
+    logCertNotInAllowlist fingerprintHex issuerTxt =
+      Logger.warn $
+        Log.msg ("Refusing IdP request: cert fingerprint not in allowlist" :: ByteString)
+          . Log.field "fingerprint" fingerprintHex
+          . Log.field "issuer" issuerTxt
+
+    guardMultiIngressCertsAllowlistNotEmpty ::
+      Maybe CertFingerprintAllowlist ->
+      NonEmpty X509.SignedCertificate ->
+      Text ->
+      Sem r ()
+    guardMultiIngressCertsAllowlistNotEmpty mAllow certs issuerTxt = do
+      samlConfig <- inputs saml
+
+      when (isEmptyAllowList mAllow && SAML.isMultiIngressConfig samlConfig) $ do
+        let fingerprintHex = renderFingerprintHex . certSha1Fingerprint . NonEmpty.head $ certs
+        logMultiIngressEmptyAllowlist fingerprintHex
+        throwSparSem (SparIdPCertNotAllowed (LText.fromStrict fingerprintHex))
+      where
+        logMultiIngressEmptyAllowlist fingerprintHex =
+          Logger.warn $
+            Log.msg ("Refusing IdP request: multi-ingress enabled and allowlist empty" :: ByteString)
+              . Log.field "fingerprint" fingerprintHex
+              . Log.field "issuer" issuerTxt
+
+        isEmptyAllowList :: Maybe CertFingerprintAllowlist -> Bool
+        isEmptyAllowList Nothing = True
+        isEmptyAllowList (Just (CertFingerprintAllowlist allowed)) | Set.null allowed = True
+        isEmptyAllowList (Just _) = False
+
 sparToServerErrorWithLogging :: (Member Reporter r) => SparError -> Sem r ServerError
 sparToServerErrorWithLogging = fmap httpErrorToServerError . renderSparErrorWithLogging
 
diff --git a/services/spar/src/Spar/Error.hs b/services/spar/src/Spar/Error.hs
index c2b8c97de65..f15cb5dfad0 100644
--- a/services/spar/src/Spar/Error.hs
+++ b/services/spar/src/Spar/Error.hs
@@ -118,6 +118,7 @@ data SparCustomError
     SparScimError Scim.ScimError
   | SparIdPDomainInUse
   | SparIdPCertNotAllowed LText
+  | SparMultiIngressIdPConfiguration LText
   deriving (Eq, Show)
 
 data SparProvisioningMoreThanOneIdP
@@ -230,6 +231,7 @@ renderSparError (SAML.CustomError (SparIdPCertNotAllowed fingerprint)) =
       status403
       "idp-cert-not-allowed"
       ("IdP certificate not in the configured allowlist: " <> fingerprint)
+renderSparError (SAML.CustomError (SparMultiIngressIdPConfiguration msg)) = StdError $ Wai.mkError status400 "multi-ingress-config-error" msg
 -- Errors related to provisioning
 renderSparError (SAML.CustomError (SparProvisioningMoreThanOneIdP msg)) = StdError $
   Wai.mkError status400 "more-than-one-idp" do
diff --git a/services/spar/test-integration/Test/Spar/AppSpec.hs b/services/spar/test-integration/Test/Spar/AppSpec.hs
index 07263bbb355..88183b9ffd3 100644
--- a/services/spar/test-integration/Test/Spar/AppSpec.hs
+++ b/services/spar/test-integration/Test/Spar/AppSpec.hs
@@ -175,7 +175,7 @@ requestAccessVerdict idp isGranted mkAuthnReq = do
           then SAML.AccessGranted uref
           else SAML.AccessDenied [DeniedNoBearerConfSubj, DeniedNoAuthnStatement]
   outcome <- do
-    runSpar $ Spar.verdictHandler (authnresp ^. rspPayload) verdict idp
+    runSpar $ Spar.verdictHandler (authnresp ^. rspPayload) verdict idp Nothing
   let loc :: URI.URI
       loc =
         maybe (error "no location") (either error id . SAML.parseURI' . cs)
diff --git a/services/spar/test/Test/Spar/Saml/IdPSpec.hs b/services/spar/test/Test/Spar/Saml/IdPSpec.hs
index 4211512e36b..5bd878e7cff 100644
--- a/services/spar/test/Test/Spar/Saml/IdPSpec.hs
+++ b/services/spar/test/Test/Spar/Saml/IdPSpec.hs
@@ -38,6 +38,8 @@ import Spar.Sem.SAML2 (SAML2 (..))
 import Spar.Sem.SAMLUserStore
 import qualified Spar.Sem.SAMLUserStore as SAMLUserStore
 import Spar.Sem.SAMLUserStore.Mem
+import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore)
+import Spar.Sem.ScimExternalIdStore.Mem (scimExternalIdStoreToMem)
 import Spar.Sem.ScimTokenStore
 import Spar.Sem.ScimTokenStore.Mem
 import qualified Spar.Sem.VerdictFormatStore as VerdictFormatStore
@@ -1075,6 +1077,7 @@ type AuthrespEffs =
      GalleyAPIAccess,
      BrigAPIAccess,
      ScimTokenStore,
+     ScimExternalIdStore,
      IdPConfigStore,
      IdPRawMetadataStore,
      Logger (Msg -> Msg),
@@ -1123,6 +1126,7 @@ interpretAuthrespE opts mbAccount triplet action = do
       . recordLogs lr
       . ignoringState idpRawMetadataStoreToMem
       . ignoringState idPToMem
+      . ignoringState scimExternalIdStoreToMem
       . ignoringState scimTokenStoreToMem
       . brigAccessMock mbAccount
       . galleyAccessMock

From adfbdc54222fffb70a6333dc5fd72087f6120650 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Mon, 3 Aug 2026 17:19:34 +0200
Subject: [PATCH 059/113] WPB-23631: Move
 `Brig.Effects.UserPendingActivationStore` in
 `Wire.UserPendingActivationStore` (#5394)

---
 changelog.d/5-internal/WPB-23631-8                            | 1 +
 .../wire-subsystems/src/Wire}/UserPendingActivationStore.hs   | 2 +-
 .../src/Wire}/UserPendingActivationStore/Cassandra.hs         | 4 ++--
 libs/wire-subsystems/wire-subsystems.cabal                    | 2 ++
 services/brig/brig.cabal                                      | 2 --
 services/brig/src/Brig/API/Internal.hs                        | 2 +-
 services/brig/src/Brig/API/Public.hs                          | 2 +-
 services/brig/src/Brig/API/User.hs                            | 4 ++--
 services/brig/src/Brig/CanonicalInterpreter.hs                | 4 ++--
 services/brig/src/Brig/Run.hs                                 | 4 ++--
 services/brig/src/Brig/Team/API.hs                            | 2 +-
 11 files changed, 15 insertions(+), 14 deletions(-)
 create mode 100644 changelog.d/5-internal/WPB-23631-8
 rename {services/brig/src/Brig/Effects => libs/wire-subsystems/src/Wire}/UserPendingActivationStore.hs (96%)
 rename {services/brig/src/Brig/Effects => libs/wire-subsystems/src/Wire}/UserPendingActivationStore/Cassandra.hs (96%)

diff --git a/changelog.d/5-internal/WPB-23631-8 b/changelog.d/5-internal/WPB-23631-8
new file mode 100644
index 00000000000..78bf843b4a5
--- /dev/null
+++ b/changelog.d/5-internal/WPB-23631-8
@@ -0,0 +1 @@
+Move `Brig.Effects.UserPendingActivationStore` in `Wire.UserPendingActivationStore`.
diff --git a/services/brig/src/Brig/Effects/UserPendingActivationStore.hs b/libs/wire-subsystems/src/Wire/UserPendingActivationStore.hs
similarity index 96%
rename from services/brig/src/Brig/Effects/UserPendingActivationStore.hs
rename to libs/wire-subsystems/src/Wire/UserPendingActivationStore.hs
index cd879e6f36d..1bf1a4c23ee 100644
--- a/services/brig/src/Brig/Effects/UserPendingActivationStore.hs
+++ b/libs/wire-subsystems/src/Wire/UserPendingActivationStore.hs
@@ -17,7 +17,7 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Brig.Effects.UserPendingActivationStore where
+module Wire.UserPendingActivationStore where
 
 import Data.Id
 import Data.Time.Clock
diff --git a/services/brig/src/Brig/Effects/UserPendingActivationStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/UserPendingActivationStore/Cassandra.hs
similarity index 96%
rename from services/brig/src/Brig/Effects/UserPendingActivationStore/Cassandra.hs
rename to libs/wire-subsystems/src/Wire/UserPendingActivationStore/Cassandra.hs
index 83dc7fe20b7..a458ab0a594 100644
--- a/services/brig/src/Brig/Effects/UserPendingActivationStore/Cassandra.hs
+++ b/libs/wire-subsystems/src/Wire/UserPendingActivationStore/Cassandra.hs
@@ -17,12 +17,11 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Brig.Effects.UserPendingActivationStore.Cassandra
+module Wire.UserPendingActivationStore.Cassandra
   ( userPendingActivationStoreToCassandra,
   )
 where
 
-import Brig.Effects.UserPendingActivationStore
 import Cassandra
 import Data.Id (UserId)
 import Data.Time (UTCTime)
@@ -30,6 +29,7 @@ import Imports
 import Polysemy
 import Polysemy.Internal.Tactics
 import Wire.Sem.Paging.Cassandra qualified as PC
+import Wire.UserPendingActivationStore
 
 userPendingActivationStoreToCassandra ::
   forall r a.
diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal
index cbdd6929d7b..cc3cf856a56 100644
--- a/libs/wire-subsystems/wire-subsystems.cabal
+++ b/libs/wire-subsystems/wire-subsystems.cabal
@@ -484,6 +484,8 @@ library
     Wire.UserKeyStore
     Wire.UserKeyStore.Cassandra
     Wire.UserList
+    Wire.UserPendingActivationStore
+    Wire.UserPendingActivationStore.Cassandra
     Wire.UserSearch.Metrics
     Wire.UserSearch.Migration
     Wire.UserSearch.Types
diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal
index a2baccf5dc5..dd2e4875fb2 100644
--- a/services/brig/brig.cabal
+++ b/services/brig/brig.cabal
@@ -114,8 +114,6 @@ library
     Brig.Effects.JwtTools
     Brig.Effects.PublicKeyBundle
     Brig.Effects.SFT
-    Brig.Effects.UserPendingActivationStore
-    Brig.Effects.UserPendingActivationStore.Cassandra
     Brig.Index.Eval
     Brig.Index.Options
     Brig.Index.Types
diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs
index 5959ce6be2b..9967a35dfb7 100644
--- a/services/brig/src/Brig/API/Internal.hs
+++ b/services/brig/src/Brig/API/Internal.hs
@@ -34,7 +34,6 @@ import Brig.API.User qualified as API
 import Brig.App as App
 import Brig.Data.Activation
 import Brig.Data.Connection qualified as Data
-import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore)
 import Brig.Options hiding (internalEvents)
 import Brig.Provider.API qualified as Provider
 import Brig.Team.API qualified as Team
@@ -135,6 +134,7 @@ import Wire.TeamInvitationSubsystem
 import Wire.TeamSubsystem (TeamSubsystem)
 import Wire.UserGroupSubsystem
 import Wire.UserKeyStore
+import Wire.UserPendingActivationStore (UserPendingActivationStore)
 import Wire.UserStore as UserStore
 import Wire.UserSubsystem
 import Wire.UserSubsystem qualified as User
diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs
index 7499ffa051c..91c1dca2b6f 100644
--- a/services/brig/src/Brig/API/Public.hs
+++ b/services/brig/src/Brig/API/Public.hs
@@ -48,7 +48,6 @@ import Brig.Effects.ConnectionStore
 import Brig.Effects.JwtTools (JwtTools)
 import Brig.Effects.PublicKeyBundle (PublicKeyBundle)
 import Brig.Effects.SFT
-import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore)
 import Brig.Options hiding (internalEvents)
 import Brig.Provider.API
 import Brig.Team.API qualified as Team
@@ -204,6 +203,7 @@ import Wire.TeamSubsystem qualified as TeamSubsystem
 import Wire.UserGroupSubsystem (UserGroupSubsystem)
 import Wire.UserGroupSubsystem qualified as UserGroup
 import Wire.UserKeyStore
+import Wire.UserPendingActivationStore (UserPendingActivationStore)
 import Wire.UserSearch.Types
 import Wire.UserStore (UserStore)
 import Wire.UserStore qualified as UserStore
diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs
index 26e69fd6f23..f785d6513d0 100644
--- a/services/brig/src/Brig/API/User.hs
+++ b/services/brig/src/Brig/API/User.hs
@@ -72,8 +72,6 @@ import Brig.Data.Connection (countConnections)
 import Brig.Data.Connection qualified as Data
 import Brig.Data.User
 import Brig.Effects.ConnectionStore
-import Brig.Effects.UserPendingActivationStore (UserPendingActivation (..), UserPendingActivationStore)
-import Brig.Effects.UserPendingActivationStore qualified as UserPendingActivationStore
 import Brig.IO.Intra qualified as Intra
 import Brig.Options hiding (internalEvents)
 import Brig.User.Auth.Cookie qualified as Auth
@@ -149,6 +147,8 @@ import Wire.TeamSubsystem (TeamSubsystem)
 import Wire.TeamSubsystem qualified as TeamSubsystem
 import Wire.UserGroupSubsystem
 import Wire.UserKeyStore
+import Wire.UserPendingActivationStore (UserPendingActivation (..), UserPendingActivationStore)
+import Wire.UserPendingActivationStore qualified as UserPendingActivationStore
 import Wire.UserStore (UserStore)
 import Wire.UserStore qualified as UserStore
 import Wire.UserSubsystem as User
diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs
index b16e5a0da85..55e0c1c0401 100644
--- a/services/brig/src/Brig/CanonicalInterpreter.hs
+++ b/services/brig/src/Brig/CanonicalInterpreter.hs
@@ -25,8 +25,6 @@ import Brig.Effects.ConnectionStore.Cassandra (connectionStoreToCassandra)
 import Brig.Effects.JwtTools
 import Brig.Effects.PublicKeyBundle
 import Brig.Effects.SFT (SFT, interpretSFT)
-import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore)
-import Brig.Effects.UserPendingActivationStore.Cassandra (userPendingActivationStoreToCassandra)
 import Brig.IO.Intra (runEvents)
 import Brig.Options (Settings (consumableNotifications), federationDomainConfigs, federationStrategy)
 import Brig.Options qualified as Opt
@@ -165,6 +163,8 @@ import Wire.UserGroupSubsystem
 import Wire.UserGroupSubsystem.Interpreter
 import Wire.UserKeyStore
 import Wire.UserKeyStore.Cassandra
+import Wire.UserPendingActivationStore (UserPendingActivationStore)
+import Wire.UserPendingActivationStore.Cassandra (userPendingActivationStoreToCassandra)
 import Wire.UserStore
 import Wire.UserStore.Cassandra
 import Wire.UserStore.Postgres (interpretUserStorePostgres)
diff --git a/services/brig/src/Brig/Run.hs b/services/brig/src/Brig/Run.hs
index d6ce0211772..7cc07fc7f2d 100644
--- a/services/brig/src/Brig/Run.hs
+++ b/services/brig/src/Brig/Run.hs
@@ -28,8 +28,6 @@ import Brig.AWS.SesNotification qualified as SesNotification
 import Brig.App
 import Brig.Calling qualified as Calling
 import Brig.CanonicalInterpreter
-import Brig.Effects.UserPendingActivationStore (UserPendingActivation (UserPendingActivation), UserPendingActivationStore)
-import Brig.Effects.UserPendingActivationStore qualified as UsersPendingActivationStore
 import Brig.InternalEvent.Process qualified as Internal
 import Brig.Options hiding (internalEvents)
 import Brig.Queue qualified as Queue
@@ -74,6 +72,8 @@ import Wire.DeleteQueue
 import Wire.OpenTelemetry (withTracer)
 import Wire.PostgresMigrations
 import Wire.Sem.Paging qualified as P
+import Wire.UserPendingActivationStore (UserPendingActivation (UserPendingActivation), UserPendingActivationStore)
+import Wire.UserPendingActivationStore qualified as UsersPendingActivationStore
 import Wire.UserStore
 
 -- FUTUREWORK: If any of these async threads die, we will have no clue about it
diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs
index bda4bd49134..323b260960b 100644
--- a/services/brig/src/Brig/Team/API.hs
+++ b/services/brig/src/Brig/Team/API.hs
@@ -32,7 +32,6 @@ import Brig.API.User qualified as API
 import Brig.API.Util (logEmail, logInvitationCode)
 import Brig.App as App
 import Brig.Data.User (invitationIdToUserId)
-import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore)
 import Brig.Template
 import Control.Lens (view, (^.))
 import Control.Monad.Trans.Except
@@ -85,6 +84,7 @@ import Wire.TeamInvitationSubsystem.Interpreter (toInvitation)
 import Wire.TeamSubsystem (TeamSubsystem)
 import Wire.TeamSubsystem qualified as TeamSubsystem
 import Wire.UserKeyStore
+import Wire.UserPendingActivationStore (UserPendingActivationStore)
 import Wire.UserStore
 import Wire.UserSubsystem
 import Wire.UserSubsystem.Error

From 3f7f621992b9d73057d090307e640c4132c1e740 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Mon, 3 Aug 2026 21:45:08 +0200
Subject: [PATCH 060/113] WPB-23631: Move `Brig.Effects.JwtTools` in
 `Wire.JwtTools` (#5396)

---
 changelog.d/5-internal/WPB-23631-10           |  1 +
 libs/wire-subsystems/default.nix              |  3 ++
 .../wire-subsystems/src/Wire}/JwtTools.hs     | 28 +++++++++++++++++--
 libs/wire-subsystems/wire-subsystems.cabal    |  2 ++
 services/brig/brig.cabal                      |  1 -
 services/brig/src/Brig/API/Error.hs           |  1 +
 services/brig/src/Brig/API/Public.hs          |  2 +-
 services/brig/src/Brig/API/Types.hs           | 11 --------
 .../brig/src/Brig/CanonicalInterpreter.hs     |  2 +-
 services/brig/src/Brig/User/Client.hs         |  5 ++--
 10 files changed, 37 insertions(+), 19 deletions(-)
 create mode 100644 changelog.d/5-internal/WPB-23631-10
 rename {services/brig/src/Brig/Effects => libs/wire-subsystems/src/Wire}/JwtTools.hs (81%)

diff --git a/changelog.d/5-internal/WPB-23631-10 b/changelog.d/5-internal/WPB-23631-10
new file mode 100644
index 00000000000..20fcd06f292
--- /dev/null
+++ b/changelog.d/5-internal/WPB-23631-10
@@ -0,0 +1 @@
+Move `Brig.Effects.JwtTools` in `Wire.JwtTools`.
diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix
index 5e175b8677d..4d3b1d9cbd0 100644
--- a/libs/wire-subsystems/default.nix
+++ b/libs/wire-subsystems/default.nix
@@ -74,6 +74,7 @@
 , imports
 , iproute
 , iso639
+, jwt-tools
 , kan-extensions
 , lens
 , lens-aeson
@@ -219,6 +220,7 @@ mkDerivation {
     imports
     iproute
     iso639
+    jwt-tools
     kan-extensions
     lens
     lens-aeson
@@ -355,6 +357,7 @@ mkDerivation {
     imports
     iproute
     iso639
+    jwt-tools
     kan-extensions
     lens
     lens-aeson
diff --git a/services/brig/src/Brig/Effects/JwtTools.hs b/libs/wire-subsystems/src/Wire/JwtTools.hs
similarity index 81%
rename from services/brig/src/Brig/Effects/JwtTools.hs
rename to libs/wire-subsystems/src/Wire/JwtTools.hs
index 4163490c286..796e01247b9 100644
--- a/services/brig/src/Brig/Effects/JwtTools.hs
+++ b/libs/wire-subsystems/src/Wire/JwtTools.hs
@@ -17,9 +17,21 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Brig.Effects.JwtTools where
+-- | DPoP access-token generation effect.
+--
+-- Note: 'CertEnrollmentError' lives here (not in @wire-api@) because its
+-- 'RustError' constructor references 'Data.Jwt.Tools.DPoPTokenGenerationError'
+-- from the @jwt-tools@ FFI library; moving it to @wire-api@ would pull the
+-- @rusty_jwt_tools_ffi@ native library into the pure types package. brig keeps
+-- importing it from here unchanged.
+module Wire.JwtTools
+  ( JwtTools (..),
+    generateDPoPAccessToken,
+    interpretJwtTools,
+    CertEnrollmentError (..),
+  )
+where
 
-import Brig.API.Types (CertEnrollmentError (..))
 import Control.Monad.Trans.Except
 import Data.ByteString.Conversion
 import Data.Handle (Handle, fromHandle)
@@ -39,6 +51,18 @@ import Wire.API.MLS.Epoch (Epoch (..))
 import Wire.API.User.Client.DPoPAccessToken (DPoPAccessToken (..), Proof (..))
 import Wire.API.User.Profile (Name (..))
 
+-- | Moved from "Brig.API.Types": kept the exact constructors. @RustError@
+-- wraps the FFI error type from "Data.Jwt.Tools".
+data CertEnrollmentError
+  = NonceNotFound
+  | RustError Jwt.DPoPTokenGenerationError
+  | KeyBundleError
+  | MisconfiguredRequestUrl
+  | ClientIdSyntaxError
+  | NotATeamUser
+  | MissingHandle
+  | MissingName
+
 data JwtTools m a where
   GenerateDPoPAccessToken ::
     -- | A DPoP proof in JWS Compact Serialization format
diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal
index cc3cf856a56..d9c828957c5 100644
--- a/libs/wire-subsystems/wire-subsystems.cabal
+++ b/libs/wire-subsystems/wire-subsystems.cabal
@@ -148,6 +148,7 @@ common common-all
     , imports
     , iproute
     , iso639
+    , jwt-tools
     , kan-extensions
     , lens
     , lens-aeson
@@ -383,6 +384,7 @@ library
     Wire.JobSubsystem.ArbiterAdapter
     Wire.JobSubsystem.Interpreter
     Wire.JobSubsystem.Migrations
+    Wire.JwtTools
     Wire.LegalHold
     Wire.LegalHoldStore
     Wire.LegalHoldStore.Cassandra
diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal
index dd2e4875fb2..3c1e4e73e31 100644
--- a/services/brig/brig.cabal
+++ b/services/brig/brig.cabal
@@ -111,7 +111,6 @@ library
     Brig.DeleteQueue.Interpreter
     Brig.Effects.ConnectionStore
     Brig.Effects.ConnectionStore.Cassandra
-    Brig.Effects.JwtTools
     Brig.Effects.PublicKeyBundle
     Brig.Effects.SFT
     Brig.Index.Eval
diff --git a/services/brig/src/Brig/API/Error.hs b/services/brig/src/Brig/API/Error.hs
index 733550000c8..c38f0d94f0a 100644
--- a/services/brig/src/Brig/API/Error.hs
+++ b/services/brig/src/Brig/API/Error.hs
@@ -30,6 +30,7 @@ import Wire.API.Federation.Error
 import Wire.API.User
 import Wire.AuthenticationSubsystem.Error
 import Wire.Error
+import Wire.JwtTools (CertEnrollmentError (..))
 
 throwStd :: (MonadError HttpError m) => Wai.Error -> m a
 throwStd = throwError . StdError
diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs
index 91c1dca2b6f..364fd051173 100644
--- a/services/brig/src/Brig/API/Public.hs
+++ b/services/brig/src/Brig/API/Public.hs
@@ -45,7 +45,6 @@ import Brig.Calling.API qualified as Calling
 import Brig.Data.Connection qualified as Data
 import Brig.Data.Nonce as Nonce
 import Brig.Effects.ConnectionStore
-import Brig.Effects.JwtTools (JwtTools)
 import Brig.Effects.PublicKeyBundle (PublicKeyBundle)
 import Brig.Effects.SFT
 import Brig.Options hiding (internalEvents)
@@ -184,6 +183,7 @@ import Wire.GalleyAPIAccess qualified as GalleyAPIAccess
 import Wire.HashPassword (HashPassword)
 import Wire.IndexedUserStore (IndexedUserStore)
 import Wire.InvitationStore
+import Wire.JwtTools (JwtTools)
 import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem)
 import Wire.NotificationSubsystem
 import Wire.PasswordResetCodeStore (PasswordResetCodeStore)
diff --git a/services/brig/src/Brig/API/Types.hs b/services/brig/src/Brig/API/Types.hs
index e12d0f646d2..f407dace853 100644
--- a/services/brig/src/Brig/API/Types.hs
+++ b/services/brig/src/Brig/API/Types.hs
@@ -33,7 +33,6 @@ where
 import Brig.Data.Activation (ActivationError (..))
 import Data.Code
 import Data.Id
-import Data.Jwt.Tools (DPoPTokenGenerationError (..))
 import Data.Qualified
 import Data.RetryAfter
 import Imports
@@ -161,16 +160,6 @@ data AccountStatusError
 data VerificationCodeThrottledError
   = VerificationCodeThrottled RetryAfter
 
-data CertEnrollmentError
-  = NonceNotFound
-  | RustError DPoPTokenGenerationError
-  | KeyBundleError
-  | MisconfiguredRequestUrl
-  | ClientIdSyntaxError
-  | NotATeamUser
-  | MissingHandle
-  | MissingName
-
 -------------------------------------------------------------------------------
 -- Exceptions
 
diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs
index 55e0c1c0401..e36378b78df 100644
--- a/services/brig/src/Brig/CanonicalInterpreter.hs
+++ b/services/brig/src/Brig/CanonicalInterpreter.hs
@@ -22,7 +22,6 @@ import Brig.App as App
 import Brig.DeleteQueue.Interpreter as DQ
 import Brig.Effects.ConnectionStore (ConnectionStore)
 import Brig.Effects.ConnectionStore.Cassandra (connectionStoreToCassandra)
-import Brig.Effects.JwtTools
 import Brig.Effects.PublicKeyBundle
 import Brig.Effects.SFT (SFT, interpretSFT)
 import Brig.IO.Intra (runEvents)
@@ -110,6 +109,7 @@ import Wire.IndexedUserStore
 import Wire.IndexedUserStore.ElasticSearch
 import Wire.InvitationStore (InvitationStore)
 import Wire.InvitationStore.Cassandra (interpretInvitationStoreToCassandra)
+import Wire.JwtTools
 import Wire.MigrationLock
 import Wire.MlsKeyPackageStore (MlsKeyPackageStore)
 import Wire.MlsKeyPackageStore.Cassandra (interpretMlsKeyPackageStoreToCassandra)
diff --git a/services/brig/src/Brig/User/Client.hs b/services/brig/src/Brig/User/Client.hs
index ed89f86db6d..372afa752fd 100644
--- a/services/brig/src/Brig/User/Client.hs
+++ b/services/brig/src/Brig/User/Client.hs
@@ -20,11 +20,8 @@ module Brig.User.Client
   )
 where
 
-import Brig.API.Types
 import Brig.App
 import Brig.Data.Nonce as Nonce
-import Brig.Effects.JwtTools (JwtTools)
-import Brig.Effects.JwtTools qualified as JwtTools
 import Brig.Effects.PublicKeyBundle (PublicKeyBundle)
 import Brig.Effects.PublicKeyBundle qualified as PublicKeyBundle
 import Brig.Options qualified as Opt
@@ -47,6 +44,8 @@ import Wire.API.MLS.Epoch (addToEpoch)
 import Wire.API.Routes.Internal.Brig
 import Wire.API.User
 import Wire.API.User.Client.DPoPAccessToken
+import Wire.JwtTools (CertEnrollmentError (..), JwtTools)
+import Wire.JwtTools qualified as JwtTools
 import Wire.Sem.FromUTC (FromUTC (fromUTCTime))
 import Wire.Sem.Now as Now
 import Wire.UserSubsystem (UserSubsystem)

From 06a57e125f3c6af3fcbdbda7a95b95b627907bdd Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Mon, 3 Aug 2026 21:45:45 +0200
Subject: [PATCH 061/113] WPB-23631: Move `Brig.Effects.SFT` in `Wire.SFT`
 (#5395)

---
 changelog.d/5-internal/WPB-23631-9                              | 1 +
 .../src/Brig/Effects => libs/wire-subsystems/src/Wire}/SFT.hs   | 2 +-
 libs/wire-subsystems/wire-subsystems.cabal                      | 1 +
 services/brig/brig.cabal                                        | 1 -
 services/brig/src/Brig/API/Public.hs                            | 2 +-
 services/brig/src/Brig/Calling/API.hs                           | 2 +-
 services/brig/src/Brig/CanonicalInterpreter.hs                  | 2 +-
 services/brig/test/unit/Test/Brig/Calling.hs                    | 2 +-
 8 files changed, 7 insertions(+), 6 deletions(-)
 create mode 100644 changelog.d/5-internal/WPB-23631-9
 rename {services/brig/src/Brig/Effects => libs/wire-subsystems/src/Wire}/SFT.hs (99%)

diff --git a/changelog.d/5-internal/WPB-23631-9 b/changelog.d/5-internal/WPB-23631-9
new file mode 100644
index 00000000000..c92b91d0427
--- /dev/null
+++ b/changelog.d/5-internal/WPB-23631-9
@@ -0,0 +1 @@
+Move `Brig.Effects.SFT` in `Wire.SFT`.
diff --git a/services/brig/src/Brig/Effects/SFT.hs b/libs/wire-subsystems/src/Wire/SFT.hs
similarity index 99%
rename from services/brig/src/Brig/Effects/SFT.hs
rename to libs/wire-subsystems/src/Wire/SFT.hs
index 9eccfd3e4c5..b86484fe008 100644
--- a/services/brig/src/Brig/Effects/SFT.hs
+++ b/libs/wire-subsystems/src/Wire/SFT.hs
@@ -17,7 +17,7 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Brig.Effects.SFT
+module Wire.SFT
   ( SFTError (..),
     SFTGetResponse (..),
     SFT (..),
diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal
index d9c828957c5..943f8fa0294 100644
--- a/libs/wire-subsystems/wire-subsystems.cabal
+++ b/libs/wire-subsystems/wire-subsystems.cabal
@@ -445,6 +445,7 @@ library
     Wire.ServiceStore.Cassandra
     Wire.SessionStore
     Wire.SessionStore.Cassandra
+    Wire.SFT
     Wire.SparAPIAccess
     Wire.SparAPIAccess.Rpc
     Wire.StoredConversation
diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal
index 3c1e4e73e31..9420bbf2189 100644
--- a/services/brig/brig.cabal
+++ b/services/brig/brig.cabal
@@ -112,7 +112,6 @@ library
     Brig.Effects.ConnectionStore
     Brig.Effects.ConnectionStore.Cassandra
     Brig.Effects.PublicKeyBundle
-    Brig.Effects.SFT
     Brig.Index.Eval
     Brig.Index.Options
     Brig.Index.Types
diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs
index 364fd051173..24de612e3ed 100644
--- a/services/brig/src/Brig/API/Public.hs
+++ b/services/brig/src/Brig/API/Public.hs
@@ -46,7 +46,6 @@ import Brig.Data.Connection qualified as Data
 import Brig.Data.Nonce as Nonce
 import Brig.Effects.ConnectionStore
 import Brig.Effects.PublicKeyBundle (PublicKeyBundle)
-import Brig.Effects.SFT
 import Brig.Options hiding (internalEvents)
 import Brig.Provider.API
 import Brig.Team.API qualified as Team
@@ -189,6 +188,7 @@ import Wire.NotificationSubsystem
 import Wire.PasswordResetCodeStore (PasswordResetCodeStore)
 import Wire.PropertySubsystem
 import Wire.RateLimit
+import Wire.SFT
 import Wire.Sem.Concurrency
 import Wire.Sem.Jwk (Jwk)
 import Wire.Sem.Metrics (Metrics)
diff --git a/services/brig/src/Brig/Calling/API.hs b/services/brig/src/Brig/Calling/API.hs
index 78588a606d3..a84f14067c3 100644
--- a/services/brig/src/Brig/Calling/API.hs
+++ b/services/brig/src/Brig/Calling/API.hs
@@ -36,7 +36,6 @@ import Brig.App
 import Brig.Calling
 import Brig.Calling qualified as Calling
 import Brig.Calling.Internal
-import Brig.Effects.SFT
 import Brig.Options (ListAllSFTServers (..))
 import Brig.Options qualified as Opt
 import Control.Error (hush, throwE)
@@ -64,6 +63,7 @@ import Wire.API.Team.Feature
 import Wire.Error
 import Wire.GalleyAPIAccess (GalleyAPIAccess, getAllTeamFeaturesForUser)
 import Wire.Network.DNS.SRV (srvTarget)
+import Wire.SFT
 
 -- | ('UserId', 'ConnId' are required as args here to make sure this is an authenticated end-point.)
 getCallsConfigV2 ::
diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs
index e36378b78df..a39da9d12a3 100644
--- a/services/brig/src/Brig/CanonicalInterpreter.hs
+++ b/services/brig/src/Brig/CanonicalInterpreter.hs
@@ -23,7 +23,6 @@ import Brig.DeleteQueue.Interpreter as DQ
 import Brig.Effects.ConnectionStore (ConnectionStore)
 import Brig.Effects.ConnectionStore.Cassandra (connectionStoreToCassandra)
 import Brig.Effects.PublicKeyBundle
-import Brig.Effects.SFT (SFT, interpretSFT)
 import Brig.IO.Intra (runEvents)
 import Brig.Options (Settings (consumableNotifications), federationDomainConfigs, federationStrategy)
 import Brig.Options qualified as Opt
@@ -132,6 +131,7 @@ import Wire.RateLimit.Interpreter
 import Wire.Rpc
 import Wire.SAMLEmailSubsystem
 import Wire.SAMLEmailSubsystem.Interpreter
+import Wire.SFT (SFT, interpretSFT)
 import Wire.Sem.Concurrency
 import Wire.Sem.Concurrency.IO
 import Wire.Sem.Delay
diff --git a/services/brig/test/unit/Test/Brig/Calling.hs b/services/brig/test/unit/Test/Brig/Calling.hs
index a7ac13c3ebb..67d38fad326 100644
--- a/services/brig/test/unit/Test/Brig/Calling.hs
+++ b/services/brig/test/unit/Test/Brig/Calling.hs
@@ -23,7 +23,6 @@ module Test.Brig.Calling (tests) where
 import Brig.Calling
 import Brig.Calling.API
 import Brig.Calling.Internal
-import Brig.Effects.SFT
 import Brig.Options
 import Control.Concurrent.Timeout qualified as System
 import Control.Lens ((^.))
@@ -52,6 +51,7 @@ import UnliftIO.Async qualified as Async
 import Wire.API.Call.Config
 import Wire.Network.DNS.Effect
 import Wire.Network.DNS.SRV
+import Wire.SFT
 import Wire.Sem.Logger.TinyLog
 
 data FakeDNSEnv = FakeDNSEnv

From 6cd6bfc71977f1a95e2c597005ce895d16248164 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Tue, 4 Aug 2026 15:30:35 +0200
Subject: [PATCH 062/113] fix: stop comparing event order in
 `testMeetingMLSAddParticipant` (#5410)

---
 integration/test/Test/Meetings.hs | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs
index 711732b0f95..61d2484b6c1 100644
--- a/integration/test/Test/Meetings.hs
+++ b/integration/test/Test/Meetings.hs
@@ -84,13 +84,13 @@ testMeetingMLSAddParticipant = do
       sequenceNotifs <- replicateM 3 (awaitMatch isMeetingAddSequenceNotif ws)
       sequenceTypes <- for sequenceNotifs $ \notif -> notif %. "payload.0.type" >>= asString
       sequenceTypes
-        `shouldMatch` [ "conversation.member-join",
-                        "meeting.member-add",
-                        "conversation.mls-welcome"
-                      ]
-      case sequenceNotifs of
-        [_, notif, _] -> pure notif
-        _ -> error "expected exactly three meeting-add sequence notifications"
+        `shouldMatchSet` [ "conversation.member-join",
+                           "meeting.member-add",
+                           "conversation.mls-welcome"
+                         ]
+      case [n | (t, n) <- zip sequenceTypes sequenceNotifs, t == "meeting.member-add"] of
+        (notif : _) -> pure notif
+        [] -> assertFailure "expected a meeting.member-add notification in the add sequence"
 
   assertMeetingNotif memberAddNotif (meeting %. "qualified_id")
   memberAddNotif %. "payload.0.qualified_conversation" `shouldMatch` convQid

From 5c30cc6ab75b33349726235e52c990740f71aead Mon Sep 17 00:00:00 2001
From: Leonhardt Wille 
Date: Tue, 4 Aug 2026 17:44:59 +0200
Subject: [PATCH 063/113] chore(charts): make alpine images configurable
 (#5408)

Bump alpine 3.21.3 -> 3.24.1 (3.21 is EOL end of October 2026)
Fix release version stamping to not accidentally overwrite alpine image tags in values.yaml

related to WPB-18320
---
 ...-alpine-images-cannon-cassandra-migrations | 19 +++++++++++++++++++
 .../templates/migrate-schema.yaml             |  3 ++-
 charts/cassandra-migrations/values.yaml       |  9 +++++++++
 .../templates/cannon/statefulset.yaml         |  3 ++-
 charts/wire-server/values.yaml                |  8 ++++++++
 deploy/dockerephemeral/docker-compose.yaml    |  2 +-
 hack/bin/set-chart-image-version.sh           |  8 ++++++++
 hack/bin/set-wire-server-image-version.sh     | 13 +++++++++++--
 renovate.json                                 | 14 ++++++++++++++
 9 files changed, 74 insertions(+), 5 deletions(-)
 create mode 100644 changelog.d/5-internal/configurable-alpine-images-cannon-cassandra-migrations

diff --git a/changelog.d/5-internal/configurable-alpine-images-cannon-cassandra-migrations b/changelog.d/5-internal/configurable-alpine-images-cannon-cassandra-migrations
new file mode 100644
index 00000000000..92119fa9353
--- /dev/null
+++ b/changelog.d/5-internal/configurable-alpine-images-cannon-cassandra-migrations
@@ -0,0 +1,19 @@
+The alpine base images used by the `cannon-configurator` initContainer (`wire-server`
+chart) and the `job-done` container (`cassandra-migrations` chart) are no longer
+hard-coded. They can now be set via `cannon.configuratorImage.{repository,tag,pullPolicy}`
+in the `wire-server` chart and `jobDoneImage.{repository,tag}` in the
+`cassandra-migrations` chart.
+
+The default was bumped from `alpine:3.21.3` to `alpine:3.24.1`, since alpine 3.21
+reaches end-of-support on 2026-11-01. The local integration stack's `init_vhosts`
+container also moved from `alpine/curl:3.14` (an Alpine 3.14 image last published
+in 2021) to `alpine/curl:8.21.0`.
+
+Operators who mirror images into a private registry should make sure the new
+`alpine:3.24.1` tag is cached, or override `repository` to point at their mirror.
+
+The chart release tooling (`hack/bin/set-wire-server-image-version.sh`,
+`hack/bin/set-chart-image-version.sh`) now anchors its version stamping to
+`repository: quay.io/wire/` lines instead of matching `tag:` by indentation, so
+third-party image tags in `values.yaml` are no longer overwritten with the
+wire-server release version.
diff --git a/charts/cassandra-migrations/templates/migrate-schema.yaml b/charts/cassandra-migrations/templates/migrate-schema.yaml
index af51d187145..8cf6a1bfe82 100644
--- a/charts/cassandra-migrations/templates/migrate-schema.yaml
+++ b/charts/cassandra-migrations/templates/migrate-schema.yaml
@@ -166,7 +166,8 @@ spec:
 
       containers:
         - name: job-done
-          image: alpine:3.21.3
+          image: "{{ .Values.jobDoneImage.repository }}:{{ .Values.jobDoneImage.tag }}"
+          imagePullPolicy: {{ default "" .Values.imagePullPolicy | quote }}
         {{- if eq (include "includeSecurityContext" .) "true" }}
           securityContext:
             {{- toYaml .Values.podSecurityContext | nindent 12 }}
diff --git a/charts/cassandra-migrations/values.yaml b/charts/cassandra-migrations/values.yaml
index a66fc6bc231..01993f08da1 100644
--- a/charts/cassandra-migrations/values.yaml
+++ b/charts/cassandra-migrations/values.yaml
@@ -79,6 +79,15 @@ enableBrigMigrations: true
 enableGundeckMigrations: true
 enableSparMigrations: true
 
+# Image for the `job-done` container, which only runs a single `echo` to mark
+# the migration Job complete. Not a wire image, so it is versioned
+# independently of images.tag above. Override repository to pull from a
+# mirror registry, e.g. my-mirror.example/library/alpine
+# renovate: datasource=docker depName=alpine
+jobDoneImage:
+  repository: alpine
+  tag: "3.24.1"
+
 podSecurityContext:
   allowPrivilegeEscalation: false
   capabilities:
diff --git a/charts/wire-server/templates/cannon/statefulset.yaml b/charts/wire-server/templates/cannon/statefulset.yaml
index ecc842b511a..00103604bf8 100644
--- a/charts/wire-server/templates/cannon/statefulset.yaml
+++ b/charts/wire-server/templates/cannon/statefulset.yaml
@@ -143,7 +143,8 @@ spec:
           {{- toYaml .Values.cannon.resources | nindent 10 }}
       initContainers:
       - name: cannon-configurator
-        image: alpine:3.21.3
+        image: "{{ .Values.cannon.configuratorImage.repository }}:{{ .Values.cannon.configuratorImage.tag }}"
+        imagePullPolicy: "{{ .Values.cannon.configuratorImage.pullPolicy }}"
         {{- if eq (include "includeSecurityContext" .) "true" }}
         securityContext:
           {{- toYaml .Values.cannon.podSecurityContext | nindent 10 }}
diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml
index ae71d850795..89e688a2ef0 100644
--- a/charts/wire-server/values.yaml
+++ b/charts/wire-server/values.yaml
@@ -518,6 +518,14 @@ cannon:
     repository: quay.io/wire/nginz
     tag: do-not-use
     pullPolicy: IfNotPresent
+  # Image for the cannon-configurator initContainer, which only runs a
+  # single `echo` into a shared volume. Override repository to pull from a
+  # mirror registry, e.g. my-mirror.example/library/alpine
+  # renovate: datasource=docker depName=alpine
+  configuratorImage:
+    repository: alpine
+    tag: "3.24.1"
+    pullPolicy: IfNotPresent
   config:
     logLevel: Info
     logFormat: StructuredJSON
diff --git a/deploy/dockerephemeral/docker-compose.yaml b/deploy/dockerephemeral/docker-compose.yaml
index e88b284487c..c4ff725a119 100644
--- a/deploy/dockerephemeral/docker-compose.yaml
+++ b/deploy/dockerephemeral/docker-compose.yaml
@@ -334,7 +334,7 @@ services:
       - demo_wire
 
   init_vhosts:
-    image: alpine/curl:3.14
+    image: alpine/curl:8.21.0
     environment:
       - RABBITMQ_USERNAME=${RABBITMQ_USERNAME}
       - RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD}
diff --git a/hack/bin/set-chart-image-version.sh b/hack/bin/set-chart-image-version.sh
index f975d00eb6a..f66bbdf1821 100755
--- a/hack/bin/set-chart-image-version.sh
+++ b/hack/bin/set-chart-image-version.sh
@@ -12,6 +12,14 @@ do
 if [[ "$chart" == "nginz" ]]; then
     # nginz has a different docker tag indentation
     sed -i "s/^    tag: .*/    tag: $docker_tag/g" "$CHARTS_DIR/$chart/values.yaml"
+elif [[ "$chart" == "wire-server" ]]; then
+    # Anchored to quay.io/wire/ repository: lines so non-wire images (cannon's
+    # alpine configuratorImage) keep their own tag instead of being stamped.
+    sed -i -E "/^[[:space:]]*repository: quay\.io\/wire\//{n; s/^([[:space:]]*)tag: .*/\1tag: $docker_tag/}" "$CHARTS_DIR/$chart/values.yaml"
+elif [[ "$chart" == "cassandra-migrations" ]]; then
+    # cassandra-migrations shares one images.tag with no adjacent repository:
+    # line, so anchor to the images: block to avoid stamping jobDoneImage.
+    sed -i -E "/^images:/,/^[^[:space:]]/ s/^  tag: .*/  tag: $docker_tag/" "$CHARTS_DIR/$chart/values.yaml"
 else
     sed -i "s/^  tag: .*/  tag: $docker_tag/g" "$CHARTS_DIR/$chart/values.yaml"
 fi
diff --git a/hack/bin/set-wire-server-image-version.sh b/hack/bin/set-wire-server-image-version.sh
index 98ecf4f30af..7c924909d41 100755
--- a/hack/bin/set-wire-server-image-version.sh
+++ b/hack/bin/set-wire-server-image-version.sh
@@ -11,7 +11,14 @@ charts=(proxy cassandra-migrations elasticsearch-index federator backoffice inte
 for chart in "${charts[@]}"; do
     values_file="$CHARTS_DIR/$chart/values.yaml"
     if [[ -f "$values_file" ]]; then
-        sed -i "s/^  tag: .*/  tag: $target_version/g" "$values_file"
+        if [[ "$chart" == "cassandra-migrations" ]]; then
+            # cassandra-migrations shares one images.tag across several images and
+            # has no adjacent repository: line, so anchor to the images: block to
+            # avoid stamping the (non-wire) jobDoneImage tag.
+            sed -i -E "/^images:/,/^[^[:space:]]/ s/^  tag: .*/  tag: $target_version/" "$values_file"
+        else
+            sed -i "s/^  tag: .*/  tag: $target_version/g" "$values_file"
+        fi
     fi
 done
 
@@ -19,4 +26,6 @@ done
 sed -i "s/^    tag: .*/    tag: $target_version/g" "$CHARTS_DIR/nginz/values.yaml"
 
 # Brig, Galley, Cargohold, BackgroundWorker, Cannon, Gundeck, and Spar are inlined into the umbrella chart.
-sed -i "s/^    tag: .*/    tag: $target_version/g" "$CHARTS_DIR/wire-server/values.yaml"
+# Anchored to quay.io/wire/ repository: lines so non-wire images (cannon's alpine
+# configuratorImage) keep their own tag instead of being stamped.
+sed -i -E "/^[[:space:]]*repository: quay\.io\/wire\//{n; s/^([[:space:]]*)tag: .*/\1tag: $target_version/}" "$CHARTS_DIR/wire-server/values.yaml"
diff --git a/renovate.json b/renovate.json
index d993844ca33..6472da35fc8 100644
--- a/renovate.json
+++ b/renovate.json
@@ -37,6 +37,20 @@
       ]
     }
   ],
+  "customManagers": [
+    {
+      "customType": "regex",
+      "managerFilePatterns": [
+        "/charts/.+/values\\.yaml$/"
+      ],
+      "matchStrings": [
+        "# renovate: datasource=(?\\S+) depName=(?\\S+)\\s+[^\\n]*\\n\\s*repository:[^\\n]*\\n\\s*tag:\\s*[\"']?(?[^\"'\\s]+)[\"']?"
+      ],
+      "datasourceTemplate": "{{{datasource}}}",
+      "depNameTemplate": "{{{depName}}}",
+      "versioningTemplate": "docker"
+    }
+  ],
   "vulnerabilityAlerts": {
     "enabled": true
   },

From 959a93540fcb72a1c797daad5e9a1edbc0e58a65 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Tue, 4 Aug 2026 18:04:02 +0200
Subject: [PATCH 064/113] fix: flaky testFederatorNumRequestsMetrics test
 (#5413)

---
 integration/test/Test/Federator.hs | 26 +++++++++++++++-----------
 1 file changed, 15 insertions(+), 11 deletions(-)

diff --git a/integration/test/Test/Federator.hs b/integration/test/Test/Federator.hs
index 5fd237ca88b..cda48844f8d 100644
--- a/integration/test/Test/Federator.hs
+++ b/integration/test/Test/Federator.hs
@@ -50,18 +50,22 @@ testFederatorMetricsExternal = runFederatorMetrics federatorExternal
 
 testFederatorNumRequestsMetrics :: (HasCallStack) => App ()
 testFederatorNumRequestsMetrics = do
-  u1 <- randomUser OwnDomain def
-  u2 <- randomUser OtherDomain def
-  incomingBefore <- getMetric parseIncomingRequestCount OtherDomain OwnDomain
-  outgoingBefore <- getMetric parseOutgoingRequestCount OwnDomain OtherDomain
-  bindResponse (searchContacts u1 (u2 %. "name") OtherDomain) $ \resp ->
-    resp.status `shouldMatchInt` 200
-  incomingAfter <- getMetric parseIncomingRequestCount OtherDomain OwnDomain
-  outgoingAfter <- getMetric parseOutgoingRequestCount OwnDomain OtherDomain
-  assertBool "Incoming requests count should have increased by at least 2" $ incomingAfter >= incomingBefore + 2
-  assertBool "Outgoing requests count should have increased by at least 2" $ outgoingAfter >= outgoingBefore + 2
+  startDynamicBackends [def, def] $ \[d1, d2] -> do
+    u1 <- randomUser d1 def
+    u2 <- randomUser d2 def
+    incomingBefore <- getMetric parseIncomingRequestCount d2 d1
+    outgoingBefore <- getMetric parseOutgoingRequestCount d1 d2
+    bindResponse (searchContacts u1 (u2 %. "name") d2) $ \resp ->
+      resp.status `shouldMatchInt` 200
+    -- Metrics are updated asynchronously in the federator, so we need to
+    -- poll until they reflect the requests triggered by searchContacts.
+    eventually $ do
+      incomingAfter <- getMetric parseIncomingRequestCount d2 d1
+      outgoingAfter <- getMetric parseOutgoingRequestCount d1 d2
+      assertBool "Incoming requests count should have increased by at least 2" $ incomingAfter >= incomingBefore + 2
+      assertBool "Outgoing requests count should have increased by at least 2" $ outgoingAfter >= outgoingBefore + 2
   where
-    getMetric :: (Text -> Parser Integer) -> Domain -> Domain -> App Integer
+    getMetric :: (Text -> Parser Integer) -> String -> String -> App Integer
     getMetric p domain origin = do
       m <- getMetrics domain federatorInternal
       d <- cs <$> asString origin

From 6ce8f54d840d56bdebf20087c06406b86ecf05bd Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Tue, 4 Aug 2026 18:05:20 +0200
Subject: [PATCH 065/113] WPB-23631: Move `Spar.Sem.ScimExternalIdStore` in
 `Wire.ScimExternalIdStore` (#5392)

---
 changelog.d/5-internal/WPB-23631-6            |   1 +
 libs/wire-api/src/Wire/API/User/Scim.hs       |   8 +-
 .../src/Wire}/ScimExternalIdStore.hs          |   3 +-
 .../Wire}/ScimExternalIdStore/Cassandra.hs    |  23 +-
 .../src/Wire}/ScimExternalIdStore/Mem.hs      |   9 +-
 .../src/Wire/ScimExternalIdStore/Spec.hs      | 321 ++++++++++++++++++
 libs/wire-subsystems/wire-subsystems.cabal    |   4 +
 services/spar/spar.cabal                      |   4 -
 services/spar/src/Spar/API.hs                 |   2 +-
 services/spar/src/Spar/App.hs                 |   4 +-
 .../spar/src/Spar/CanonicalInterpreter.hs     |   4 +-
 services/spar/src/Spar/Data/Instances.hs      |  13 -
 services/spar/src/Spar/Scim.hs                |   2 +-
 services/spar/src/Spar/Scim/Types.hs          |   8 -
 services/spar/src/Spar/Scim/User.hs           |   6 +-
 .../src/Spar/Sem/ScimExternalIdStore/Spec.hs  | 203 -----------
 .../Test/Spar/Scim/UserSpec.hs                |   2 +-
 services/spar/test-integration/Util/Core.hs   |   2 +-
 services/spar/test/Arbitrary.hs               |   1 -
 services/spar/test/Test/Spar/Saml/IdPSpec.hs  |   4 +-
 services/spar/test/Test/Spar/Scim/UserSpec.hs |   4 +-
 .../Test/Spar/Sem/ScimExternalIdStoreSpec.hs  |   4 +-
 22 files changed, 373 insertions(+), 259 deletions(-)
 create mode 100644 changelog.d/5-internal/WPB-23631-6
 rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/ScimExternalIdStore.hs (97%)
 rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/ScimExternalIdStore/Cassandra.hs (86%)
 rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/ScimExternalIdStore/Mem.hs (88%)
 create mode 100644 libs/wire-subsystems/src/Wire/ScimExternalIdStore/Spec.hs
 delete mode 100644 services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs

diff --git a/changelog.d/5-internal/WPB-23631-6 b/changelog.d/5-internal/WPB-23631-6
new file mode 100644
index 00000000000..527fc5d0fab
--- /dev/null
+++ b/changelog.d/5-internal/WPB-23631-6
@@ -0,0 +1 @@
+Move `Spar.Sem.ScimExternalIdStore` in `Wire.ScimExternalIdStore`.
diff --git a/libs/wire-api/src/Wire/API/User/Scim.hs b/libs/wire-api/src/Wire/API/User/Scim.hs
index 8ca092eed10..cda70e95803 100644
--- a/libs/wire-api/src/Wire/API/User/Scim.hs
+++ b/libs/wire-api/src/Wire/API/User/Scim.hs
@@ -70,7 +70,7 @@ import Imports
 import SAML2.WebSSO qualified as SAML
 import SAML2.WebSSO.Test.Arbitrary ()
 import Servant.API (FromHttpApiData (..), ToHttpApiData (..))
-import Test.QuickCheck (Gen)
+import Test.QuickCheck (Gen, elements)
 import Test.QuickCheck qualified as QC
 import Web.HttpApiData (parseHeaderWithPrefix)
 import Web.Scim.AttrName (AttrName (..))
@@ -515,3 +515,9 @@ newtype ScimTokenName = ScimTokenName {fromScimTokenName :: Text}
 
 instance ToSchema ScimTokenName where
   schema = object $ ScimTokenName <$> fromScimTokenName .= field "name" schema
+
+data ScimUserCreationStatus = ScimUserCreating | ScimUserCreated
+  deriving (Eq, Show, Generic)
+
+instance Arbitrary ScimUserCreationStatus where
+  arbitrary = elements [ScimUserCreating, ScimUserCreated]
diff --git a/services/spar/src/Spar/Sem/ScimExternalIdStore.hs b/libs/wire-subsystems/src/Wire/ScimExternalIdStore.hs
similarity index 97%
rename from services/spar/src/Spar/Sem/ScimExternalIdStore.hs
rename to libs/wire-subsystems/src/Wire/ScimExternalIdStore.hs
index c4bb2b54ed6..f88533358a4 100644
--- a/services/spar/src/Spar/Sem/ScimExternalIdStore.hs
+++ b/libs/wire-subsystems/src/Wire/ScimExternalIdStore.hs
@@ -17,7 +17,7 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Spar.Sem.ScimExternalIdStore
+module Wire.ScimExternalIdStore
   ( ScimExternalIdStore (..),
     insert,
     lookup,
@@ -32,7 +32,6 @@ import Data.Text
 import Imports (Maybe, Show)
 import Polysemy
 import Polysemy.Check (deriveGenericK)
-import Spar.Scim.Types
 import Wire.API.User.Scim
 
 data ScimExternalIdStore m a where
diff --git a/services/spar/src/Spar/Sem/ScimExternalIdStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Cassandra.hs
similarity index 86%
rename from services/spar/src/Spar/Sem/ScimExternalIdStore/Cassandra.hs
rename to libs/wire-subsystems/src/Wire/ScimExternalIdStore/Cassandra.hs
index 42d098dfe33..cf27706db00 100644
--- a/services/spar/src/Spar/Sem/ScimExternalIdStore/Cassandra.hs
+++ b/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Cassandra.hs
@@ -1,3 +1,4 @@
+{-# OPTIONS_GHC -Wno-orphans #-}
 {-# OPTIONS_GHC -Wno-redundant-constraints #-}
 
 -- This file is part of the Wire Server implementation.
@@ -17,7 +18,7 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Spar.Sem.ScimExternalIdStore.Cassandra
+module Wire.ScimExternalIdStore.Cassandra
   ( scimExternalIdStoreToCassandra,
   )
 where
@@ -27,10 +28,22 @@ import Data.Bifunctor (second)
 import Data.Id
 import Imports
 import Polysemy
-import Spar.Data.Instances ()
-import Spar.Scim.Types (ScimUserCreationStatus (ScimUserCreated))
-import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore (..))
-import Wire.API.User.Scim (ValidScimId (..))
+import Wire.API.User.Scim (ScimUserCreationStatus (..), ValidScimId (..))
+import Wire.ScimExternalIdStore (ScimExternalIdStore (..))
+
+-- This is the only consumer of the @scim_external.creation_status@ column,
+-- so the Cql instance lives here.
+instance Cql ScimUserCreationStatus where
+  ctype = Tagged IntColumn
+
+  toCql ScimUserCreated = CqlInt 0
+  toCql ScimUserCreating = CqlInt 1
+
+  fromCql (CqlInt i) = case i of
+    0 -> pure ScimUserCreated
+    1 -> pure ScimUserCreating
+    n -> Left $ "unexpected ScimUserCreationStatus: " ++ show n
+  fromCql _ = Left "int expected"
 
 scimExternalIdStoreToCassandra ::
   forall m r a.
diff --git a/services/spar/src/Spar/Sem/ScimExternalIdStore/Mem.hs b/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Mem.hs
similarity index 88%
rename from services/spar/src/Spar/Sem/ScimExternalIdStore/Mem.hs
rename to libs/wire-subsystems/src/Wire/ScimExternalIdStore/Mem.hs
index 5ab14ccd4af..7f35b69d7f3 100644
--- a/services/spar/src/Spar/Sem/ScimExternalIdStore/Mem.hs
+++ b/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Mem.hs
@@ -17,19 +17,18 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Spar.Sem.ScimExternalIdStore.Mem
+module Wire.ScimExternalIdStore.Mem
   ( scimExternalIdStoreToMem,
   )
 where
 
 import Data.Id (TeamId, UserId)
-import qualified Data.Map as M
+import Data.Map qualified as M
 import Imports
 import Polysemy
 import Polysemy.State
-import Spar.Scim.Types (ScimUserCreationStatus)
-import Spar.Sem.ScimExternalIdStore
-import Wire.API.User.Scim (ValidScimId (..))
+import Wire.API.User.Scim (ScimUserCreationStatus, ValidScimId (..))
+import Wire.ScimExternalIdStore
 
 scimExternalIdStoreToMem ::
   Sem (ScimExternalIdStore ': r) a ->
diff --git a/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Spec.hs b/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Spec.hs
new file mode 100644
index 00000000000..dbdb25ff4aa
--- /dev/null
+++ b/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Spec.hs
@@ -0,0 +1,321 @@
+{-# LANGUAGE QuantifiedConstraints #-}
+{-# OPTIONS_GHC -fplugin=Polysemy.Plugin #-}
+
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2022 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Wire.ScimExternalIdStore.Spec (propsForInterpreter) where
+
+import Data.Id
+import Imports
+import Polysemy
+import Polysemy.Check
+import Test.Hspec
+import Test.Hspec.QuickCheck
+import Test.QuickCheck
+import Wire.API.User.Scim (ScimUserCreationStatus, ValidScimId)
+import Wire.ScimExternalIdStore qualified as E
+
+propsForInterpreter ::
+  (PropConstraints r f) =>
+  String ->
+  (forall a. f a -> a) ->
+  (forall a. Sem r a -> IO (f a)) ->
+  Spec
+propsForInterpreter interpreter extract lower = do
+  describe interpreter $ do
+    prop "delete/delete" $ prop_deleteDelete Nothing lower
+    prop "delete/lookup" $ prop_deleteLookup (Just $ show . void . extract) lower
+    prop "delete/insert" $ prop_deleteInsert Nothing lower
+    prop "lookup/insert" $ prop_lookupInsert Nothing lower
+    prop "insert/delete" $ prop_insertDelete Nothing lower
+    prop "insert/lookup" $ prop_insertLookup (Just $ show . void . extract) lower
+    prop "insert/insert" $ prop_insertInsert (Just $ show . void . extract) lower
+    prop "insertStatus/lookupStatus" $ prop_insertStatusLookupStatus (Just $ show . void . extract) lower
+    prop "insertStatus/insertStatus" $ prop_insertStatusInsertStatus (Just $ show . void . extract) lower
+    prop "lookupStatus/insertStatus" $ prop_lookupStatusInsertStatus Nothing lower
+
+-- | All the constraints we need to generalize properties in this module.
+-- A regular type synonym doesn't work due to dreaded impredicative
+-- polymorphism.
+class
+  (Arbitrary UserId, CoArbitrary UserId, Arbitrary ValidScimId, Arbitrary ScimUserCreationStatus, CoArbitrary ScimUserCreationStatus, Functor f, Member E.ScimExternalIdStore r, forall z. (Show z) => Show (f z), forall z. (Eq z) => Eq (f z)) =>
+  PropConstraints r f
+
+instance
+  (CoArbitrary UserId, CoArbitrary ScimUserCreationStatus, Functor f, Member E.ScimExternalIdStore r, forall z. (Show z) => Show (f z), forall z. (Eq z) => Eq (f z)) =>
+  PropConstraints r f
+
+-- | Adapt the fully-polymorphic interpreter to the rank-2 position 'prepropLaw'
+-- expects. 'prepropLaw' wants @forall z. Sem r (a, z) -> IO (f (a, z))@ for the
+-- law's result type @a@, while callers hand us @forall x. Sem r x -> IO (f x)@.
+-- Passing @lower@ directly trips GHC's shallow subsumption under this package's
+-- extension set (GHC2021); the explicit eta-expansion forces instantiation at
+-- the application site and is always safe. The sibling specs still in
+-- @services/spar@ (Haskell2010) pass the interpreter point-free.
+lowerAsLaw ::
+  (forall x. Sem r x -> IO (f x)) ->
+  (forall z. Sem r (a, z) -> IO (f (a, z)))
+lowerAsLaw lower sem = lower sem
+
+prop_insertLookup ::
+  (PropConstraints r f) =>
+  Maybe (f (Maybe UserId) -> String) ->
+  (forall a. Sem r a -> IO (f a)) ->
+  Property
+prop_insertLookup shrinkFn lower =
+  prepropLaw @'[E.ScimExternalIdStore]
+    ( do
+        tid <- arbitrary
+        email <- arbitrary
+        uid <- arbitrary
+        pure $
+          simpleLaw
+            ( do
+                E.insert tid email uid
+                E.lookup tid email
+            )
+            ( do
+                E.insert tid email uid
+                pure (Just uid)
+            )
+    )
+    shrinkFn
+    (lowerAsLaw lower)
+
+prop_lookupInsert ::
+  (PropConstraints r f) =>
+  Maybe (f () -> String) ->
+  (forall a. Sem r a -> IO (f a)) ->
+  Property
+prop_lookupInsert shrinkFn lower =
+  prepropLaw @'[E.ScimExternalIdStore]
+    ( do
+        tid <- arbitrary
+        email <- arbitrary
+        pure $
+          simpleLaw
+            ( do
+                E.lookup tid email >>= maybe (pure ()) (E.insert tid email)
+            )
+            ( do
+                pure ()
+            )
+    )
+    shrinkFn
+    (lowerAsLaw lower)
+
+prop_insertDelete ::
+  (PropConstraints r f) =>
+  Maybe (f () -> String) ->
+  (forall a. Sem r a -> IO (f a)) ->
+  Property
+prop_insertDelete shrinkFn lower =
+  prepropLaw @'[E.ScimExternalIdStore]
+    ( do
+        tid <- arbitrary
+        email <- arbitrary
+        uid <- arbitrary
+        pure $
+          simpleLaw
+            ( do
+                E.insert tid email uid
+                E.delete tid email
+            )
+            ( do
+                E.delete tid email
+            )
+    )
+    shrinkFn
+    (lowerAsLaw lower)
+
+prop_deleteInsert ::
+  (PropConstraints r f) =>
+  Maybe (f () -> String) ->
+  (forall a. Sem r a -> IO (f a)) ->
+  Property
+prop_deleteInsert shrinkFn lower =
+  prepropLaw @'[E.ScimExternalIdStore]
+    ( do
+        tid <- arbitrary
+        email <- arbitrary
+        uid <- arbitrary
+        pure $
+          simpleLaw
+            ( do
+                E.delete tid email
+                E.insert tid email uid
+            )
+            ( do
+                E.insert tid email uid
+            )
+    )
+    shrinkFn
+    (lowerAsLaw lower)
+
+prop_insertInsert ::
+  (PropConstraints r f) =>
+  Maybe (f (Maybe UserId) -> String) ->
+  (forall a. Sem r a -> IO (f a)) ->
+  Property
+prop_insertInsert shrinkFn lower =
+  prepropLaw @'[E.ScimExternalIdStore]
+    ( do
+        tid <- arbitrary
+        email <- arbitrary
+        uid <- arbitrary
+        uid' <- arbitrary
+        pure $
+          simpleLaw
+            ( do
+                E.insert tid email uid
+                E.insert tid email uid'
+                E.lookup tid email
+            )
+            ( do
+                E.insert tid email uid'
+                E.lookup tid email
+            )
+    )
+    shrinkFn
+    (lowerAsLaw lower)
+
+prop_insertStatusLookupStatus ::
+  (PropConstraints r f) =>
+  Maybe (f (Maybe (UserId, ScimUserCreationStatus)) -> String) ->
+  (forall a. Sem r a -> IO (f a)) ->
+  Property
+prop_insertStatusLookupStatus shrinkFn lower =
+  prepropLaw @'[E.ScimExternalIdStore]
+    ( do
+        tid <- arbitrary
+        veid <- arbitrary
+        uid <- arbitrary
+        status <- arbitrary
+        pure $
+          simpleLaw
+            ( do
+                E.insertStatus tid veid uid status
+                E.lookupStatus tid veid
+            )
+            ( do
+                E.insertStatus tid veid uid status
+                pure (Just (uid, status))
+            )
+    )
+    shrinkFn
+    (lowerAsLaw lower)
+
+prop_insertStatusInsertStatus ::
+  (PropConstraints r f) =>
+  Maybe (f (Maybe (UserId, ScimUserCreationStatus)) -> String) ->
+  (forall a. Sem r a -> IO (f a)) ->
+  Property
+prop_insertStatusInsertStatus shrinkFn lower =
+  prepropLaw @'[E.ScimExternalIdStore]
+    ( do
+        tid <- arbitrary
+        veid <- arbitrary
+        uid1 <- arbitrary
+        status1 <- arbitrary
+        uid2 <- arbitrary
+        status2 <- arbitrary
+        pure $
+          simpleLaw
+            ( do
+                E.insertStatus tid veid uid1 status1
+                E.insertStatus tid veid uid2 status2
+                E.lookupStatus tid veid
+            )
+            ( do
+                E.insertStatus tid veid uid2 status2
+                pure (Just (uid2, status2))
+            )
+    )
+    shrinkFn
+    (lowerAsLaw lower)
+
+prop_lookupStatusInsertStatus ::
+  (PropConstraints r f) =>
+  Maybe (f () -> String) ->
+  (forall a. Sem r a -> IO (f a)) ->
+  Property
+prop_lookupStatusInsertStatus shrinkFn lower =
+  prepropLaw @'[E.ScimExternalIdStore]
+    ( do
+        tid <- arbitrary
+        veid <- arbitrary
+        pure $
+          simpleLaw
+            ( do
+                E.lookupStatus tid veid >>= maybe (pure ()) (uncurry (E.insertStatus tid veid))
+            )
+            ( do
+                pure ()
+            )
+    )
+    shrinkFn
+    (lowerAsLaw lower)
+
+prop_deleteDelete ::
+  (PropConstraints r f) =>
+  Maybe (f () -> String) ->
+  (forall a. Sem r a -> IO (f a)) ->
+  Property
+prop_deleteDelete shrinkFn lower =
+  prepropLaw @'[E.ScimExternalIdStore]
+    ( do
+        tid <- arbitrary
+        email <- arbitrary
+        pure $
+          simpleLaw
+            ( do
+                E.delete tid email
+                E.delete tid email
+            )
+            ( do
+                E.delete tid email
+            )
+    )
+    shrinkFn
+    (lowerAsLaw lower)
+
+prop_deleteLookup ::
+  (PropConstraints r f) =>
+  Maybe (f (Maybe UserId) -> String) ->
+  (forall a. Sem r a -> IO (f a)) ->
+  Property
+prop_deleteLookup shrinkFn lower =
+  prepropLaw @'[E.ScimExternalIdStore]
+    ( do
+        tid <- arbitrary
+        email <- arbitrary
+        uid <- arbitrary
+        pure $
+          Law
+            { lawLhs = do
+                E.delete tid email
+                E.lookup tid email,
+              lawRhs = do
+                E.delete tid email
+                pure Nothing,
+              lawPrelude = [E.insert tid email uid],
+              lawPostlude = [] @(Sem _ ())
+            }
+    )
+    shrinkFn
+    (lowerAsLaw lower)
diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal
index 943f8fa0294..58d99be707b 100644
--- a/libs/wire-subsystems/wire-subsystems.cabal
+++ b/libs/wire-subsystems/wire-subsystems.cabal
@@ -435,6 +435,10 @@ library
     Wire.SAMLEmailSubsystem.Interpreter
     Wire.SamlProtocolSettings
     Wire.SamlProtocolSettings.Servant
+    Wire.ScimExternalIdStore
+    Wire.ScimExternalIdStore.Cassandra
+    Wire.ScimExternalIdStore.Mem
+    Wire.ScimExternalIdStore.Spec
     Wire.ScimSubsystem
     Wire.ScimSubsystem.Error
     Wire.ScimSubsystem.Interpreter
diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal
index 5b0482f74c9..ccef39babe1 100644
--- a/services/spar/spar.cabal
+++ b/services/spar/spar.cabal
@@ -76,10 +76,6 @@ library
     Spar.Sem.SAMLUserStore
     Spar.Sem.SAMLUserStore.Cassandra
     Spar.Sem.SAMLUserStore.Mem
-    Spar.Sem.ScimExternalIdStore
-    Spar.Sem.ScimExternalIdStore.Cassandra
-    Spar.Sem.ScimExternalIdStore.Mem
-    Spar.Sem.ScimExternalIdStore.Spec
     Spar.Sem.ScimTokenStore
     Spar.Sem.ScimTokenStore.Cassandra
     Spar.Sem.ScimTokenStore.Mem
diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs
index 7458d0b7384..670562d989c 100644
--- a/services/spar/src/Spar/API.hs
+++ b/services/spar/src/Spar/API.hs
@@ -100,7 +100,6 @@ import Spar.Sem.SAML2 (SAML2)
 import qualified Spar.Sem.SAML2 as SAML2
 import Spar.Sem.SAMLUserStore (SAMLUserStore)
 import qualified Spar.Sem.SAMLUserStore as SAMLUserStore
-import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore)
 import Spar.Sem.ScimTokenStore (ScimTokenStore)
 import qualified Spar.Sem.ScimTokenStore as ScimTokenStore
 import Spar.Sem.VerdictFormatStore (VerdictFormatStore)
@@ -132,6 +131,7 @@ import qualified Wire.IdPSubsystem as IdPSubsystem
 import Wire.Reporter (Reporter)
 import Wire.SamlProtocolSettings (SamlProtocolSettings)
 import qualified Wire.SamlProtocolSettings as SamlProtocolSettings
+import Wire.ScimExternalIdStore (ScimExternalIdStore)
 import Wire.ScimSubsystem
 import Wire.ScimUserTimesStore (ScimUserTimesStore)
 import qualified Wire.ScimUserTimesStore as ScimUserTimesStore
diff --git a/services/spar/src/Spar/App.hs b/services/spar/src/Spar/App.hs
index 64e401b88e2..dc1c950fd4e 100644
--- a/services/spar/src/Spar/App.hs
+++ b/services/spar/src/Spar/App.hs
@@ -85,8 +85,6 @@ import Spar.Orphans ()
 import Spar.Sem.AReqIDStore (AReqIDStore)
 import Spar.Sem.SAMLUserStore (SAMLUserStore)
 import qualified Spar.Sem.SAMLUserStore as SAMLUserStore
-import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore)
-import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore
 import Spar.Sem.ScimTokenStore (ScimTokenStore)
 import qualified Spar.Sem.ScimTokenStore as ScimTokenStore
 import Spar.Sem.VerdictFormatStore (VerdictFormatStore)
@@ -110,6 +108,8 @@ import Wire.IdPConfigStore (IdPConfigStore)
 import qualified Wire.IdPConfigStore as IdPConfigStore
 import Wire.Reporter (Reporter)
 import qualified Wire.Reporter as Reporter
+import Wire.ScimExternalIdStore (ScimExternalIdStore)
+import qualified Wire.ScimExternalIdStore as ScimExternalIdStore
 import Wire.ScimSubsystem.Interpreter
 import Wire.Sem.Logger (Logger)
 import qualified Wire.Sem.Logger as Logger
diff --git a/services/spar/src/Spar/CanonicalInterpreter.hs b/services/spar/src/Spar/CanonicalInterpreter.hs
index 6b5e5fec4c6..b2a4405b850 100644
--- a/services/spar/src/Spar/CanonicalInterpreter.hs
+++ b/services/spar/src/Spar/CanonicalInterpreter.hs
@@ -45,8 +45,6 @@ import Spar.Sem.SAML2 (SAML2)
 import Spar.Sem.SAML2.Library (saml2ToSaml2WebSso)
 import Spar.Sem.SAMLUserStore (SAMLUserStore)
 import Spar.Sem.SAMLUserStore.Cassandra (samlUserStoreToCassandra)
-import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore)
-import Spar.Sem.ScimExternalIdStore.Cassandra (scimExternalIdStoreToCassandra)
 import Spar.Sem.ScimTokenStore (ScimTokenStore)
 import Spar.Sem.ScimTokenStore.Cassandra (scimTokenStoreToCassandra)
 import Spar.Sem.Utils
@@ -75,6 +73,8 @@ import Wire.Rpc (Rpc, runRpcWithHttp)
 import Wire.RpcException
 import Wire.SamlProtocolSettings (SamlProtocolSettings)
 import Wire.SamlProtocolSettings.Servant (sparRouteToServant)
+import Wire.ScimExternalIdStore (ScimExternalIdStore)
+import Wire.ScimExternalIdStore.Cassandra (scimExternalIdStoreToCassandra)
 import Wire.ScimSubsystem
 import Wire.ScimSubsystem.Interpreter
 import Wire.ScimUserTimesStore (ScimUserTimesStore)
diff --git a/services/spar/src/Spar/Data/Instances.hs b/services/spar/src/Spar/Data/Instances.hs
index 1bd89c0c377..2aec0f7602d 100644
--- a/services/spar/src/Spar/Data/Instances.hs
+++ b/services/spar/src/Spar/Data/Instances.hs
@@ -38,7 +38,6 @@ import Data.Functor.Alt (Alt (()))
 import qualified Data.Text.Encoding as T
 import Data.Text.Encoding.Error
 import Imports
-import Spar.Scim.Types (ScimUserCreationStatus (..))
 import URI.ByteString
 import Wire.API.User.Auth
 import Wire.API.User.Saml
@@ -90,15 +89,3 @@ instance Cql ScimTokenLookupKey where
     (ScimTokenLookupKeyHashed <$> fromCql s)
        (ScimTokenLookupKeyPlaintext <$> fromCql s)
   fromCql _ = Left "ScimTokenLookupKey: expected CqlText"
-
-instance Cql ScimUserCreationStatus where
-  ctype = Tagged IntColumn
-
-  toCql ScimUserCreated = CqlInt 0
-  toCql ScimUserCreating = CqlInt 1
-
-  fromCql (CqlInt i) = case i of
-    0 -> pure ScimUserCreated
-    1 -> pure ScimUserCreating
-    n -> Left $ "unexpected ScimUserCreationStatus: " ++ show n
-  fromCql _ = Left "int expected"
diff --git a/services/spar/src/Spar/Scim.hs b/services/spar/src/Spar/Scim.hs
index e67dfa231d2..02e6b5a60c6 100644
--- a/services/spar/src/Spar/Scim.hs
+++ b/services/spar/src/Spar/Scim.hs
@@ -84,7 +84,6 @@ import Spar.Scim.Auth
 import Spar.Scim.Group ()
 import Spar.Scim.User
 import Spar.Sem.SAMLUserStore (SAMLUserStore)
-import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore)
 import Spar.Sem.ScimTokenStore (ScimTokenStore)
 import System.Logger (Msg)
 import qualified Web.Scim.Capabilities.MetaSchema as Scim.Meta
@@ -100,6 +99,7 @@ import Wire.BrigAPIAccess (BrigAPIAccess)
 import Wire.GalleyAPIAccess (GalleyAPIAccess)
 import Wire.IdPConfigStore (IdPConfigStore)
 import Wire.Reporter (Reporter)
+import Wire.ScimExternalIdStore (ScimExternalIdStore)
 import Wire.ScimSubsystem
 import Wire.ScimUserTimesStore (ScimUserTimesStore)
 import Wire.Sem.Logger (Logger)
diff --git a/services/spar/src/Spar/Scim/Types.hs b/services/spar/src/Spar/Scim/Types.hs
index b2b6b360af7..abda3fb9a81 100644
--- a/services/spar/src/Spar/Scim/Types.hs
+++ b/services/spar/src/Spar/Scim/Types.hs
@@ -32,8 +32,6 @@ module Spar.Scim.Types where
 
 import Control.Lens (view)
 import Imports
-import Test.QuickCheck (Arbitrary (..))
-import Test.QuickCheck.Gen (elements)
 import qualified Web.Scim.Schema.Common as Scim
 import qualified Web.Scim.Schema.User as Scim.User
 import Wire.API.User (AccountStatus (..))
@@ -89,9 +87,3 @@ normalizeLikeStored usr =
 
     tweakActive :: Maybe Scim.ScimBool -> Maybe Scim.ScimBool
     tweakActive = Just . Scim.ScimBool . maybe True Scim.unScimBool
-
-data ScimUserCreationStatus = ScimUserCreating | ScimUserCreated
-  deriving (Eq, Show, Generic)
-
-instance Arbitrary ScimUserCreationStatus where
-  arbitrary = elements [ScimUserCreating, ScimUserCreated]
diff --git a/services/spar/src/Spar/Scim/User.hs b/services/spar/src/Spar/Scim/User.hs
index b6ebc5dbab1..a8b54e2de02 100644
--- a/services/spar/src/Spar/Scim/User.hs
+++ b/services/spar/src/Spar/Scim/User.hs
@@ -81,8 +81,6 @@ import Spar.Scim.Types
 import qualified Spar.Scim.Types as ST
 import Spar.Sem.SAMLUserStore (SAMLUserStore)
 import qualified Spar.Sem.SAMLUserStore as SAMLUserStore
-import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore)
-import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore
 import qualified System.Logger.Class as Log
 import System.Logger.Message (Msg)
 import qualified URI.ByteString as URIBS
@@ -104,7 +102,7 @@ import Wire.API.Team.Role
 import Wire.API.User
 import Wire.API.User.IdentityProvider (IdP)
 import qualified Wire.API.User.RichInfo as RI
-import Wire.API.User.Scim (ScimTokenInfo (..), ValidScimId (..))
+import Wire.API.User.Scim (ScimTokenInfo (..), ScimUserCreationStatus (..), ValidScimId (..))
 import qualified Wire.API.User.Scim as ST
 import Wire.BrigAPIAccess (BrigAPIAccess)
 import qualified Wire.BrigAPIAccess as BrigAPIAccess
@@ -112,6 +110,8 @@ import Wire.GalleyAPIAccess (GalleyAPIAccess)
 import qualified Wire.GalleyAPIAccess as GalleyAPIAccess
 import Wire.IdPConfigStore (IdPConfigStore)
 import qualified Wire.IdPConfigStore as IdPConfigStore
+import Wire.ScimExternalIdStore (ScimExternalIdStore)
+import qualified Wire.ScimExternalIdStore as ScimExternalIdStore
 import Wire.ScimUserTimesStore (ScimUserTimesStore)
 import qualified Wire.ScimUserTimesStore as ScimUserTimesStore
 import Wire.Sem.Logger (Logger)
diff --git a/services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs b/services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs
deleted file mode 100644
index eab1ba7d47f..00000000000
--- a/services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs
+++ /dev/null
@@ -1,203 +0,0 @@
-{-# LANGUAGE QuantifiedConstraints #-}
-{-# OPTIONS_GHC -fplugin=Polysemy.Plugin #-}
-
--- This file is part of the Wire Server implementation.
---
--- Copyright (C) 2022 Wire Swiss GmbH 
---
--- This program is free software: you can redistribute it and/or modify it under
--- the terms of the GNU Affero General Public License as published by the Free
--- Software Foundation, either version 3 of the License, or (at your option) any
--- later version.
---
--- This program is distributed in the hope that it will be useful, but WITHOUT
--- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
--- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
--- details.
---
--- You should have received a copy of the GNU Affero General Public License along
--- with this program. If not, see .
-
-module Spar.Sem.ScimExternalIdStore.Spec (propsForInterpreter) where
-
-import Data.Id
-import Imports
-import Polysemy
-import Polysemy.Check
-import Spar.Scim.Types (ScimUserCreationStatus)
-import qualified Spar.Sem.ScimExternalIdStore as E
-import Test.Hspec
-import Test.Hspec.QuickCheck
-import Test.QuickCheck
-
-propsForInterpreter ::
-  (PropConstraints r f) =>
-  String ->
-  (forall a. f a -> a) ->
-  (forall a. Sem r a -> IO (f a)) ->
-  Spec
-propsForInterpreter interpreter extract lower = do
-  describe interpreter $ do
-    prop "delete/delete" $ prop_deleteDelete Nothing lower
-    prop "delete/lookup" $ prop_deleteLookup (Just $ show . void . extract) lower
-    prop "delete/insert" $ prop_deleteInsert Nothing lower
-    prop "lookup/insert" $ prop_lookupInsert Nothing lower
-    prop "insert/delete" $ prop_insertDelete Nothing lower
-    prop "insert/lookup" $ prop_insertLookup (Just $ show . void . extract) lower
-    prop "insert/insert" $ prop_insertInsert (Just $ show . void . extract) lower
-
--- FUTUREWORK: Add prop tests for missing operations
-
--- | All the constraints we need to generalize properties in this module.
--- A regular type synonym doesn't work due to dreaded impredicative
--- polymorphism.
-class
-  (Arbitrary UserId, CoArbitrary UserId, Arbitrary ScimUserCreationStatus, CoArbitrary ScimUserCreationStatus, Functor f, Member E.ScimExternalIdStore r, forall z. (Show z) => Show (f z), forall z. (Eq z) => Eq (f z)) =>
-  PropConstraints r f
-
-instance
-  (CoArbitrary UserId, CoArbitrary ScimUserCreationStatus, Functor f, Member E.ScimExternalIdStore r, forall z. (Show z) => Show (f z), forall z. (Eq z) => Eq (f z)) =>
-  PropConstraints r f
-
-prop_insertLookup ::
-  (PropConstraints r f) =>
-  Maybe (f (Maybe UserId) -> String) ->
-  (forall a. Sem r a -> IO (f a)) ->
-  Property
-prop_insertLookup =
-  prepropLaw @'[E.ScimExternalIdStore] $ do
-    tid <- arbitrary
-    email <- arbitrary
-    uid <- arbitrary
-    pure $
-      simpleLaw
-        ( do
-            E.insert tid email uid
-            E.lookup tid email
-        )
-        ( do
-            E.insert tid email uid
-            pure (Just uid)
-        )
-
-prop_lookupInsert ::
-  (PropConstraints r f) =>
-  Maybe (f () -> String) ->
-  (forall a. Sem r a -> IO (f a)) ->
-  Property
-prop_lookupInsert =
-  prepropLaw @'[E.ScimExternalIdStore] $ do
-    tid <- arbitrary
-    email <- arbitrary
-    pure $
-      simpleLaw
-        ( do
-            E.lookup tid email >>= maybe (pure ()) (E.insert tid email)
-        )
-        ( do
-            pure ()
-        )
-
-prop_insertDelete ::
-  (PropConstraints r f) =>
-  Maybe (f () -> String) ->
-  (forall a. Sem r a -> IO (f a)) ->
-  Property
-prop_insertDelete =
-  prepropLaw @'[E.ScimExternalIdStore] $ do
-    tid <- arbitrary
-    email <- arbitrary
-    uid <- arbitrary
-    pure $
-      simpleLaw
-        ( do
-            E.insert tid email uid
-            E.delete tid email
-        )
-        ( do
-            E.delete tid email
-        )
-
-prop_deleteInsert ::
-  (PropConstraints r f) =>
-  Maybe (f () -> String) ->
-  (forall a. Sem r a -> IO (f a)) ->
-  Property
-prop_deleteInsert =
-  prepropLaw @'[E.ScimExternalIdStore] $ do
-    tid <- arbitrary
-    email <- arbitrary
-    uid <- arbitrary
-    pure $
-      simpleLaw
-        ( do
-            E.delete tid email
-            E.insert tid email uid
-        )
-        ( do
-            E.insert tid email uid
-        )
-
-prop_insertInsert ::
-  (PropConstraints r f) =>
-  Maybe (f (Maybe UserId) -> String) ->
-  (forall a. Sem r a -> IO (f a)) ->
-  Property
-prop_insertInsert =
-  prepropLaw @'[E.ScimExternalIdStore] $ do
-    tid <- arbitrary
-    email <- arbitrary
-    uid <- arbitrary
-    uid' <- arbitrary
-    pure $
-      simpleLaw
-        ( do
-            E.insert tid email uid
-            E.insert tid email uid'
-            E.lookup tid email
-        )
-        ( do
-            E.insert tid email uid'
-            E.lookup tid email
-        )
-
-prop_deleteDelete ::
-  (PropConstraints r f) =>
-  Maybe (f () -> String) ->
-  (forall a. Sem r a -> IO (f a)) ->
-  Property
-prop_deleteDelete =
-  prepropLaw @'[E.ScimExternalIdStore] $ do
-    tid <- arbitrary
-    email <- arbitrary
-    pure $
-      simpleLaw
-        ( do
-            E.delete tid email
-            E.delete tid email
-        )
-        ( do
-            E.delete tid email
-        )
-
-prop_deleteLookup ::
-  (PropConstraints r f) =>
-  Maybe (f (Maybe UserId) -> String) ->
-  (forall a. Sem r a -> IO (f a)) ->
-  Property
-prop_deleteLookup =
-  prepropLaw @'[E.ScimExternalIdStore] $ do
-    tid <- arbitrary
-    email <- arbitrary
-    uid <- arbitrary
-    pure $
-      Law
-        { lawLhs = do
-            E.delete tid email
-            E.lookup tid email,
-          lawRhs = do
-            E.delete tid email
-            pure Nothing,
-          lawPrelude = [E.insert tid email uid],
-          lawPostlude = [] @(Sem _ ())
-        }
diff --git a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs
index 1ea57c883b8..545f1ff8977 100644
--- a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs
+++ b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs
@@ -69,7 +69,6 @@ import Spar.Scim
 import Spar.Scim.Types (normalizeLikeStored)
 import qualified Spar.Scim.User as SU
 import qualified Spar.Sem.SAMLUserStore as SAMLUserStore
-import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore
 import Test.Tasty.HUnit ((@?=))
 import qualified Text.XML.DSig as SAML
 import Util
@@ -95,6 +94,7 @@ import Wire.API.User.RichInfo
 import qualified Wire.API.User.Scim as Spar.Types
 import qualified Wire.API.User.Search as Search
 import qualified Wire.BrigAPIAccess as BrigAPIAccess
+import qualified Wire.ScimExternalIdStore as ScimExternalIdStore
 import qualified Wire.ScimUserTimesStore as ScimUserTimesStore
 
 -- | Tests for @\/scim\/v2\/Users@.
diff --git a/services/spar/test-integration/Util/Core.hs b/services/spar/test-integration/Util/Core.hs
index b172bac74b1..4fe1e23e92f 100644
--- a/services/spar/test-integration/Util/Core.hs
+++ b/services/spar/test-integration/Util/Core.hs
@@ -186,7 +186,6 @@ import qualified Spar.Intra.RpcApp as Intra
 import Spar.Options
 import Spar.Run
 import qualified Spar.Sem.SAMLUserStore as SAMLUserStore
-import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore
 import qualified System.Logger.Extended as Log
 import System.Random (randomRIO)
 import Test.Hspec hiding (it, pending, pendingWith, xit)
@@ -218,6 +217,7 @@ import Wire.API.User.IdentityProvider
 import Wire.API.User.Scim
 import Wire.BrigAPIAccess (getAccount)
 import qualified Wire.IdPConfigStore as IdPConfigStore
+import qualified Wire.ScimExternalIdStore as ScimExternalIdStore
 
 -- | Call 'mkEnv' with options from config files.
 mkEnvFromOptions :: IO TestEnv
diff --git a/services/spar/test/Arbitrary.hs b/services/spar/test/Arbitrary.hs
index 65a02e8bb07..82089277c58 100644
--- a/services/spar/test/Arbitrary.hs
+++ b/services/spar/test/Arbitrary.hs
@@ -33,7 +33,6 @@ import SAML2.WebSSO.Test.Arbitrary ()
 import SAML2.WebSSO.Types
 import Servant.API.ContentTypes
 import Spar.Scim
-import Spar.Scim.Types (ScimUserCreationStatus)
 import Test.QuickCheck
 import URI.ByteString
 import Wire.API.User.IdentityProvider
diff --git a/services/spar/test/Test/Spar/Saml/IdPSpec.hs b/services/spar/test/Test/Spar/Saml/IdPSpec.hs
index 5bd878e7cff..93f591a750c 100644
--- a/services/spar/test/Test/Spar/Saml/IdPSpec.hs
+++ b/services/spar/test/Test/Spar/Saml/IdPSpec.hs
@@ -38,8 +38,6 @@ import Spar.Sem.SAML2 (SAML2 (..))
 import Spar.Sem.SAMLUserStore
 import qualified Spar.Sem.SAMLUserStore as SAMLUserStore
 import Spar.Sem.SAMLUserStore.Mem
-import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore)
-import Spar.Sem.ScimExternalIdStore.Mem (scimExternalIdStoreToMem)
 import Spar.Sem.ScimTokenStore
 import Spar.Sem.ScimTokenStore.Mem
 import qualified Spar.Sem.VerdictFormatStore as VerdictFormatStore
@@ -71,6 +69,8 @@ import Wire.IdPRawMetadataStore.Mem
 import Wire.Reporter (Reporter (..))
 import Wire.SamlProtocolSettings (SamlProtocolSettings)
 import Wire.SamlProtocolSettings.Servant (sparRouteToServant)
+import Wire.ScimExternalIdStore (ScimExternalIdStore)
+import Wire.ScimExternalIdStore.Mem (scimExternalIdStoreToMem)
 import Wire.Sem.Logger (discardLogs)
 import Wire.Sem.Logger.TinyLog (LogRecorder (..), newLogRecorder, recordLogs)
 import Wire.Sem.Random
diff --git a/services/spar/test/Test/Spar/Scim/UserSpec.hs b/services/spar/test/Test/Spar/Scim/UserSpec.hs
index b8625312528..fb733fb7253 100644
--- a/services/spar/test/Test/Spar/Scim/UserSpec.hs
+++ b/services/spar/test/Test/Spar/Scim/UserSpec.hs
@@ -28,8 +28,6 @@ import Polysemy.TinyLog
 import Spar.Scim.User (deleteScimUser)
 import Spar.Sem.SAMLUserStore
 import Spar.Sem.SAMLUserStore.Mem (samlUserStoreToMem)
-import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore
-import Spar.Sem.ScimExternalIdStore.Mem (scimExternalIdStoreToMem)
 import System.Logger (Msg)
 import Test.Hspec
 import Test.QuickCheck
@@ -40,6 +38,8 @@ import Wire.BrigAPIAccess
 import Wire.IdPConfigStore
 import Wire.IdPConfigStore.Mem (idPToMem)
 import Wire.IdPConfigStore.Orphans ()
+import qualified Wire.ScimExternalIdStore as ScimExternalIdStore
+import Wire.ScimExternalIdStore.Mem (scimExternalIdStoreToMem)
 import Wire.ScimUserTimesStore
 import Wire.ScimUserTimesStore.Mem (scimUserTimesStoreToMem)
 import Wire.Sem.Logger.TinyLog (discardTinyLogs)
diff --git a/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs b/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs
index ec978251ea2..e00872f2214 100644
--- a/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs
+++ b/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs
@@ -22,10 +22,10 @@ module Test.Spar.Sem.ScimExternalIdStoreSpec where
 import Arbitrary ()
 import Imports
 import Polysemy
-import Spar.Sem.ScimExternalIdStore.Mem
-import Spar.Sem.ScimExternalIdStore.Spec
 import Test.Hspec
 import Test.Hspec.QuickCheck
+import Wire.ScimExternalIdStore.Mem
+import Wire.ScimExternalIdStore.Spec
 
 spec :: Spec
 spec = modifyMaxSuccess (const 1000) $ do

From ae3836f3ac34a7fa7d8d8ab2889f77e3d87fe6c9 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Tue, 4 Aug 2026 18:06:22 +0200
Subject: [PATCH 066/113] WPB-23631: Move `Brig.Budget` in `Wire.BudgetStore`
 (#5397)

---
 changelog.d/5-internal/WPB-23631-11           |  1 +
 .../wire-subsystems/src/Wire/BudgetStore.hs   | 55 +++++++------------
 .../src/Wire/BudgetStore/Cassandra.hs         | 55 +++++++++++++++++++
 libs/wire-subsystems/wire-subsystems.cabal    |  2 +
 services/brig/brig.cabal                      |  1 -
 services/brig/src/Brig/API/Auth.hs            |  4 +-
 services/brig/src/Brig/API/Public.hs          |  4 +-
 .../brig/src/Brig/CanonicalInterpreter.hs     |  4 ++
 services/brig/src/Brig/User/Auth.hs           | 18 +++---
 services/gundeck/src/Gundeck/ThreadBudget.hs  |  4 +-
 10 files changed, 97 insertions(+), 51 deletions(-)
 create mode 100644 changelog.d/5-internal/WPB-23631-11
 rename services/brig/src/Brig/Budget.hs => libs/wire-subsystems/src/Wire/BudgetStore.hs (60%)
 create mode 100644 libs/wire-subsystems/src/Wire/BudgetStore/Cassandra.hs

diff --git a/changelog.d/5-internal/WPB-23631-11 b/changelog.d/5-internal/WPB-23631-11
new file mode 100644
index 00000000000..aaebb0e4d24
--- /dev/null
+++ b/changelog.d/5-internal/WPB-23631-11
@@ -0,0 +1 @@
+Move `Brig.Budget` in `Wire.BudgetStore`.
diff --git a/services/brig/src/Brig/Budget.hs b/libs/wire-subsystems/src/Wire/BudgetStore.hs
similarity index 60%
rename from services/brig/src/Brig/Budget.hs
rename to libs/wire-subsystems/src/Wire/BudgetStore.hs
index 2cd24cdee95..0780ece29e0 100644
--- a/services/brig/src/Brig/Budget.hs
+++ b/libs/wire-subsystems/src/Wire/BudgetStore.hs
@@ -1,4 +1,4 @@
-{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE TemplateHaskell #-}
 
 -- This file is part of the Wire Server implementation.
 --
@@ -17,20 +17,22 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Brig.Budget
-  ( Budget (..),
-    BudgetKey (..),
-    Budgeted (..),
-    withBudget,
-    checkBudget,
+module Wire.BudgetStore
+  ( BudgetStore (..),
     lookupBudget,
     insertBudget,
+    withBudget,
+    checkBudget,
+    Budget (..),
+    BudgetKey (..),
+    Budgeted (..),
   )
 where
 
-import Cassandra
-import Data.Time.Clock
+import Cassandra (Cql)
+import Data.Time.Clock (NominalDiffTime)
 import Imports
+import Polysemy
 
 data Budget = Budget
   { budgetTimeout :: !NominalDiffTime,
@@ -46,19 +48,18 @@ data Budgeted a
 newtype BudgetKey = BudgetKey Text
   deriving (Eq, Show, Cql)
 
+data BudgetStore m a where
+  LookupBudget :: BudgetKey -> BudgetStore m (Maybe Budget)
+  InsertBudget :: BudgetKey -> Budget -> BudgetStore m ()
+
+makeSem ''BudgetStore
+
 -- | @withBudget (BudgetKey "k") (Budget 30 5) action@ runs @action@ at most 5 times every 30
 -- seconds.  @"k"@ is used for keeping different calls to 'withBudget' apart; use something
 -- there that's unique to your context, like @"login#" <> uid@.
 --
 -- See the docs in "Gundeck.ThreadBudget" for related work.
---
--- FUTUREWORK: encourage caller to define their own type for budget keys (rather than using an
--- untyped text), and represent the types in a way that guarantees that if i'm using a local
--- type that i don't export, then nobody will be able to use my namespace.
---
--- FUTUREWORK: exceptions are not handled very nicely, but it's not clear what it would mean
--- to improve this.
-withBudget :: (MonadClient m) => BudgetKey -> Budget -> m a -> m (Budgeted a)
+withBudget :: (Member BudgetStore r) => BudgetKey -> Budget -> Sem r a -> Sem r (Budgeted a)
 withBudget k b ma = do
   Budget ttl val <- fromMaybe b <$> lookupBudget k
   let remaining = val - 1
@@ -70,7 +71,7 @@ withBudget k b ma = do
       pure (BudgetedValue a remaining)
 
 -- | Like 'withBudget', but does not decrease budget, only takes a look.
-checkBudget :: (MonadClient m) => BudgetKey -> Budget -> m (Budgeted ())
+checkBudget :: (Member BudgetStore r) => BudgetKey -> Budget -> Sem r (Budgeted ())
 checkBudget k b = do
   Budget ttl val <- fromMaybe b <$> lookupBudget k
   let remaining = val - 1
@@ -78,21 +79,3 @@ checkBudget k b = do
     if remaining < 0
       then BudgetExhausted ttl
       else BudgetedValue () remaining
-
-lookupBudget :: (MonadClient m) => BudgetKey -> m (Maybe Budget)
-lookupBudget k = fmap mk <$> query1 budgetSelect (params One (Identity k))
-  where
-    mk (val, ttl) = Budget (fromIntegral ttl) val
-
-insertBudget :: (MonadClient m) => BudgetKey -> Budget -> m ()
-insertBudget k (Budget ttl val) =
-  retry x5 $ write budgetInsert (params One (k, val, round ttl))
-
--------------------------------------------------------------------------------
--- Queries
-
-budgetInsert :: PrepQuery W (BudgetKey, Int32, Int32) ()
-budgetInsert = "INSERT INTO budget (key, budget) VALUES (?, ?) USING TTL ?"
-
-budgetSelect :: PrepQuery R (Identity BudgetKey) (Int32, Int32)
-budgetSelect = "SELECT budget, ttl(budget) FROM budget where key = ?"
diff --git a/libs/wire-subsystems/src/Wire/BudgetStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/BudgetStore/Cassandra.hs
new file mode 100644
index 00000000000..ebc4df1a9d0
--- /dev/null
+++ b/libs/wire-subsystems/src/Wire/BudgetStore/Cassandra.hs
@@ -0,0 +1,55 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Wire.BudgetStore.Cassandra
+  ( budgetStoreToCassandra,
+  )
+where
+
+import Cassandra
+import Imports
+import Polysemy
+import Wire.BudgetStore
+
+budgetStoreToCassandra ::
+  forall m r a.
+  (MonadClient m, Member (Embed m) r) =>
+  Sem (BudgetStore ': r) a ->
+  Sem r a
+budgetStoreToCassandra =
+  interpret $
+    embed @m . \case
+      LookupBudget k -> lookupBudgetC k
+      InsertBudget k b -> insertBudgetC k b
+
+lookupBudgetC :: (MonadClient m) => BudgetKey -> m (Maybe Budget)
+lookupBudgetC k = fmap mk <$> query1 budgetSelect (params One (Identity k))
+  where
+    mk (val, ttl) = Budget (fromIntegral ttl) val
+
+insertBudgetC :: (MonadClient m) => BudgetKey -> Budget -> m ()
+insertBudgetC k (Budget ttl val) =
+  retry x5 $ write budgetInsert (params One (k, val, round ttl))
+
+-------------------------------------------------------------------------------
+-- Queries
+
+budgetInsert :: PrepQuery W (BudgetKey, Int32, Int32) ()
+budgetInsert = "INSERT INTO budget (key, budget) VALUES (?, ?) USING TTL ?"
+
+budgetSelect :: PrepQuery R (Identity BudgetKey) (Int32, Int32)
+budgetSelect = "SELECT budget, ttl(budget) FROM budget where key = ?"
diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal
index 58d99be707b..32865e78a22 100644
--- a/libs/wire-subsystems/wire-subsystems.cabal
+++ b/libs/wire-subsystems/wire-subsystems.cabal
@@ -245,6 +245,8 @@ library
     Wire.BoundedQueue.STM
     Wire.BrigAPIAccess
     Wire.BrigAPIAccess.Rpc
+    Wire.BudgetStore
+    Wire.BudgetStore.Cassandra
     Wire.ClientStore
     Wire.ClientStore.Cassandra
     Wire.ClientStore.DynamoDB
diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal
index 9420bbf2189..ecb51c1d058 100644
--- a/services/brig/brig.cabal
+++ b/services/brig/brig.cabal
@@ -98,7 +98,6 @@ library
     Brig.AWS
     Brig.AWS.SesNotification
     Brig.AWS.Types
-    Brig.Budget
     Brig.Calling
     Brig.Calling.API
     Brig.Calling.Internal
diff --git a/services/brig/src/Brig/API/Auth.hs b/services/brig/src/Brig/API/Auth.hs
index b47366621fc..5051540aacd 100644
--- a/services/brig/src/Brig/API/Auth.hs
+++ b/services/brig/src/Brig/API/Auth.hs
@@ -55,6 +55,7 @@ import Wire.AuthenticationSubsystem.Config
 import Wire.AuthenticationSubsystem.Error
 import Wire.AuthenticationSubsystem.ZAuth
 import Wire.BlockListStore
+import Wire.BudgetStore
 import Wire.ClientStore (ClientStore)
 import Wire.DomainRegistrationStore (DomainRegistrationStore)
 import Wire.EmailSubsystem (EmailSubsystem)
@@ -133,7 +134,8 @@ sendLoginCode _ =
   throwStd (errorToWai @'E.InvalidPhone)
 
 login ::
-  ( Member TinyLog r,
+  ( Member BudgetStore r,
+    Member TinyLog r,
     Member UserKeyStore r,
     Member UserStore r,
     Member Events r,
diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs
index 24de612e3ed..8b8d887043c 100644
--- a/services/brig/src/Brig/API/Public.hs
+++ b/services/brig/src/Brig/API/Public.hs
@@ -162,6 +162,7 @@ import Wire.AuthenticationSubsystem as AuthenticationSubsystem
 import Wire.AuthenticationSubsystem.Config (AuthenticationSubsystemConfig)
 import Wire.BackendNotificationQueueAccess
 import Wire.BlockListStore (BlockListStore)
+import Wire.BudgetStore
 import Wire.ClientStore (ClientStore)
 import Wire.ClientStore qualified as ClientStore
 import Wire.ClientSubsystem (ClientSubsystem)
@@ -428,7 +429,8 @@ servantSitemap ::
     Member ClientSubsystem r,
     Member (Error FederationError) r,
     Member BackendNotificationQueueAccess r,
-    HasBrigFederationAccess m r
+    HasBrigFederationAccess m r,
+    Member BudgetStore r
   ) =>
   ServerT BrigAPI (Handler r)
 servantSitemap =
diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs
index a39da9d12a3..145f799658f 100644
--- a/services/brig/src/Brig/CanonicalInterpreter.hs
+++ b/services/brig/src/Brig/CanonicalInterpreter.hs
@@ -69,6 +69,8 @@ import Wire.BackgroundJobsPublisher (BackgroundJobPublisher)
 import Wire.BackgroundJobsPublisher.RabbitMQ (interpretBackgroundJobPublisherRabbitMQ)
 import Wire.BlockListStore
 import Wire.BlockListStore.Cassandra
+import Wire.BudgetStore
+import Wire.BudgetStore.Cassandra
 import Wire.ClientStore (ClientStore)
 import Wire.ClientStore.Cassandra
 import Wire.ClientStore.DynamoDB (OptimisticLockEnv (..))
@@ -262,6 +264,7 @@ type BrigLowerLevelEffects =
      PublicKeyBundle,
      JwtTools,
      BlockListStore,
+     BudgetStore,
      UserPendingActivationStore InternalPaging,
      Now,
      Delay,
@@ -445,6 +448,7 @@ runBrigToIO e (AppT ma) = do
               . runDelay
               . nowToIOAction e.currentTime
               . userPendingActivationStoreToCassandra
+              . budgetStoreToCassandra @Cas.Client
               . interpretBlockListStoreToCassandra e.casClient
               . interpretJwtTools
               . interpretPublicKeyBundle
diff --git a/services/brig/src/Brig/User/Auth.hs b/services/brig/src/Brig/User/Auth.hs
index d00b6340196..51880fca8b1 100644
--- a/services/brig/src/Brig/User/Auth.hs
+++ b/services/brig/src/Brig/User/Auth.hs
@@ -36,10 +36,8 @@ where
 import Brig.API.Types
 import Brig.API.User (changeSingleAccountStatus)
 import Brig.App
-import Brig.Budget
 import Brig.Options qualified as Opt
 import Brig.User.Auth.Cookie
-import Cassandra
 import Control.Error hiding (bool)
 import Data.ByteString.Conversion (toByteString)
 import Data.Code qualified as Code
@@ -71,6 +69,7 @@ import Wire.AuthenticationSubsystem qualified as Authentication
 import Wire.AuthenticationSubsystem.Config
 import Wire.AuthenticationSubsystem.Error (VerificationCodeError (..))
 import Wire.AuthenticationSubsystem.ZAuth qualified as ZAuth
+import Wire.BudgetStore
 import Wire.ClientStore (ClientStore)
 import Wire.ClientStore qualified as ClientStore
 import Wire.Events (Events)
@@ -89,6 +88,7 @@ import Wire.UserSubsystem qualified as User
 login ::
   forall r.
   ( Member (Input (Local ())) r,
+    Member BudgetStore r,
     Member ActivationCodeStore r,
     Member Events r,
     Member TinyLog r,
@@ -108,7 +108,7 @@ login ::
 login (MkLogin li pw label code) typ = do
   uid <- resolveLoginId li
   lift . liftSem . Log.debug $ field "user" (toByteString uid) . field "action" (val "User.login")
-  wrapClientE $ checkRetryLimit uid
+  checkRetryLimit uid
 
   (lift . liftSem $ Authentication.authenticateEither uid pw) >>= \case
     Right a -> pure a
@@ -131,18 +131,16 @@ login (MkLogin li pw label code) typ = do
           Left VerificationCodeNoEmail -> lift (decrRetryLimit uid) >> throwE LoginFailed
           Right () -> pure ()
 
-decrRetryLimit :: UserId -> (AppT r) ()
-decrRetryLimit = wrapClient . withRetryLimit (\k b -> withBudget k b $ pure ())
+decrRetryLimit :: forall r. (Member BudgetStore r) => UserId -> (AppT r) ()
+decrRetryLimit = withRetryLimit (\k b -> liftSem $ withBudget k b $ pure ())
 
 checkRetryLimit ::
-  ( MonadReader Env m,
-    MonadClient m
-  ) =>
+  (Member BudgetStore r) =>
   UserId ->
-  ExceptT LoginError m ()
+  ExceptT LoginError (AppT r) ()
 checkRetryLimit uid =
   flip withRetryLimit uid $ \budgetKey budget ->
-    checkBudget budgetKey budget >>= \case
+    lift (liftSem (checkBudget budgetKey budget)) >>= \case
       BudgetExhausted ttl -> throwE . LoginBlocked . RetryAfter . floor $ ttl
       BudgetedValue () remaining -> pure $ BudgetedValue () remaining
 
diff --git a/services/gundeck/src/Gundeck/ThreadBudget.hs b/services/gundeck/src/Gundeck/ThreadBudget.hs
index f226dc379c2..34c41debe3f 100644
--- a/services/gundeck/src/Gundeck/ThreadBudget.hs
+++ b/services/gundeck/src/Gundeck/ThreadBudget.hs
@@ -15,12 +15,12 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
--- | Like "Brig.Budget", but in-memory, per host (not per service), and with an strict/exact
+-- | Like "Wire.BudgetStore", but in-memory, per host (not per service), and with an strict/exact
 -- upper bound.  Like https://hackage.haskell.org/package/token-bucket, but takes the entire
 -- run-time of the actions into account, not just the number of executions.
 -- http://hackage.haskell.org/package/rate-limit also looks related.
 -- https://github.com/juspay/fencer does what this module does, but as a networked service, so
--- in that way it works more like "Brig.Budget".
+-- in that way it works more like "Wire.BudgetStore".
 --
 -- FUTUREWORK: https://github.com/layer-3-communications/lockpool seems like almost exactly
 -- the same thing, but I only found this after ThreadBudget was done.  Before considering to

From 95f096542361344a2d75a83ed1e516c5d8965b0c Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Tue, 4 Aug 2026 22:16:45 +0200
Subject: [PATCH 067/113] WPB-23631: Move `Spar.Sem.VerdictFormatStore` in
 `Wire.VerdictFormatStore` (#5388)

---
 changelog.d/5-internal/WPB-23631-5            |  1 +
 .../src/Wire}/VerdictFormatStore.hs           |  2 +-
 .../src/Wire}/VerdictFormatStore/Cassandra.hs | 37 ++++++++++++++++--
 .../src/Wire}/VerdictFormatStore/Mem.hs       | 10 ++---
 libs/wire-subsystems/wire-subsystems.cabal    |  3 ++
 services/spar/spar.cabal                      |  3 --
 services/spar/src/Spar/API.hs                 |  4 +-
 services/spar/src/Spar/App.hs                 |  4 +-
 .../spar/src/Spar/CanonicalInterpreter.hs     |  4 +-
 services/spar/src/Spar/Data.hs                |  1 -
 services/spar/src/Spar/Data/Instances.hs      | 39 +------------------
 .../test-integration/Test/Spar/DataSpec.hs    |  2 +-
 services/spar/test/Test/Spar/Saml/IdPSpec.hs  |  2 +-
 weeder.toml                                   |  2 +-
 14 files changed, 53 insertions(+), 61 deletions(-)
 create mode 100644 changelog.d/5-internal/WPB-23631-5
 rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/VerdictFormatStore.hs (97%)
 rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/VerdictFormatStore/Cassandra.hs (65%)
 rename {services/spar/src/Spar/Sem => libs/wire-subsystems/src/Wire}/VerdictFormatStore/Mem.hs (89%)

diff --git a/changelog.d/5-internal/WPB-23631-5 b/changelog.d/5-internal/WPB-23631-5
new file mode 100644
index 00000000000..6202f2f46bc
--- /dev/null
+++ b/changelog.d/5-internal/WPB-23631-5
@@ -0,0 +1 @@
+Move `Spar.Sem.VerdictFormatStore` in `Wire.VerdictFormatStore`.
diff --git a/services/spar/src/Spar/Sem/VerdictFormatStore.hs b/libs/wire-subsystems/src/Wire/VerdictFormatStore.hs
similarity index 97%
rename from services/spar/src/Spar/Sem/VerdictFormatStore.hs
rename to libs/wire-subsystems/src/Wire/VerdictFormatStore.hs
index df8770c77c3..1d8568126c4 100644
--- a/services/spar/src/Spar/Sem/VerdictFormatStore.hs
+++ b/libs/wire-subsystems/src/Wire/VerdictFormatStore.hs
@@ -17,7 +17,7 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Spar.Sem.VerdictFormatStore
+module Wire.VerdictFormatStore
   ( VerdictFormatStore (..),
     store,
     get,
diff --git a/services/spar/src/Spar/Sem/VerdictFormatStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/VerdictFormatStore/Cassandra.hs
similarity index 65%
rename from services/spar/src/Spar/Sem/VerdictFormatStore/Cassandra.hs
rename to libs/wire-subsystems/src/Wire/VerdictFormatStore/Cassandra.hs
index 5bf82d3cbea..315038b77e5 100644
--- a/services/spar/src/Spar/Sem/VerdictFormatStore/Cassandra.hs
+++ b/libs/wire-subsystems/src/Wire/VerdictFormatStore/Cassandra.hs
@@ -1,3 +1,4 @@
+{-# OPTIONS_GHC -Wno-orphans #-}
 -- Disabling to stop warnings on HasCallStack
 {-# OPTIONS_GHC -Wno-redundant-constraints #-}
 
@@ -18,7 +19,7 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Spar.Sem.VerdictFormatStore.Cassandra
+module Wire.VerdictFormatStore.Cassandra
   ( verdictFormatStoreToCassandra,
   )
 where
@@ -28,13 +29,41 @@ import Control.Lens
 import Data.Time
 import Imports
 import Polysemy
-import Spar.Data
-import Spar.Data.Instances (VerdictFormatCon, VerdictFormatRow, fromVerdictFormat, toVerdictFormat)
-import Spar.Sem.VerdictFormatStore
 import URI.ByteString
 import Wire.API.User.Auth
 import Wire.API.User.Saml
 import Wire.IdPConfigStore.Orphans ()
+import Wire.VerdictFormatStore
+
+-- Duplicated from Spar.Data (Wire cannot depend on Spar); Spar.Data keeps its
+-- own copy for mkTTLNDT.
+nominalDiffToSeconds :: NominalDiffTime -> Int32
+nominalDiffToSeconds = round @Double . realToFrac
+
+type VerdictFormatRow = (VerdictFormatCon, Maybe URI, Maybe URI, Maybe CookieLabel)
+
+data VerdictFormatCon = VerdictFormatConWeb | VerdictFormatConMobile
+
+instance Cql VerdictFormatCon where
+  ctype = Tagged IntColumn
+
+  toCql VerdictFormatConWeb = CqlInt 0
+  toCql VerdictFormatConMobile = CqlInt 1
+
+  fromCql (CqlInt i) = case i of
+    0 -> pure VerdictFormatConWeb
+    1 -> pure VerdictFormatConMobile
+    n -> Left $ "unexpected VerdictFormatCon: " ++ show n
+  fromCql _ = Left "member-status: int expected"
+
+fromVerdictFormat :: VerdictFormat -> VerdictFormatRow
+fromVerdictFormat (VerdictFormatWeb mlabel) = (VerdictFormatConWeb, Nothing, Nothing, mlabel)
+fromVerdictFormat (VerdictFormatMobile succredir errredir mlabel) = (VerdictFormatConMobile, Just succredir, Just errredir, mlabel)
+
+toVerdictFormat :: VerdictFormatRow -> Maybe VerdictFormat
+toVerdictFormat (VerdictFormatConWeb, Nothing, Nothing, mlabel) = Just $ VerdictFormatWeb mlabel
+toVerdictFormat (VerdictFormatConMobile, Just succredir, Just errredir, mlabel) = Just $ VerdictFormatMobile succredir errredir mlabel
+toVerdictFormat _ = Nothing
 
 verdictFormatStoreToCassandra ::
   forall m r a.
diff --git a/services/spar/src/Spar/Sem/VerdictFormatStore/Mem.hs b/libs/wire-subsystems/src/Wire/VerdictFormatStore/Mem.hs
similarity index 89%
rename from services/spar/src/Spar/Sem/VerdictFormatStore/Mem.hs
rename to libs/wire-subsystems/src/Wire/VerdictFormatStore/Mem.hs
index 12ecf2368ad..11588feefcb 100644
--- a/services/spar/src/Spar/Sem/VerdictFormatStore/Mem.hs
+++ b/libs/wire-subsystems/src/Wire/VerdictFormatStore/Mem.hs
@@ -17,21 +17,21 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Spar.Sem.VerdictFormatStore.Mem
+module Wire.VerdictFormatStore.Mem
   ( verdictFormatStoreToMem,
   )
 where
 
-import qualified Data.Map as M
+import Data.Map qualified as M
 import Imports
 import Polysemy
 import Polysemy.State hiding (Get)
 import SAML2.WebSSO (addTime)
-import qualified SAML2.WebSSO.Types as SAML
-import Spar.Sem.VerdictFormatStore
+import SAML2.WebSSO.Types qualified as SAML
 import Wire.API.User.Saml (AReqId, VerdictFormat)
 import Wire.Sem.Now (Now, boolTTL)
-import qualified Wire.Sem.Now as Now
+import Wire.Sem.Now qualified as Now
+import Wire.VerdictFormatStore
 
 verdictFormatStoreToMem ::
   (Member Now r) =>
diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal
index 32865e78a22..ad2c91d6b9f 100644
--- a/libs/wire-subsystems/wire-subsystems.cabal
+++ b/libs/wire-subsystems/wire-subsystems.cabal
@@ -509,6 +509,9 @@ library
     Wire.UserSubsystem.Interpreter
     Wire.UserSubsystem.UserSubsystemConfig
     Wire.Util
+    Wire.VerdictFormatStore
+    Wire.VerdictFormatStore.Cassandra
+    Wire.VerdictFormatStore.Mem
     Wire.VerificationCode
     Wire.VerificationCodeGen
     Wire.VerificationCodeStore
diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal
index ccef39babe1..9d6b57ab137 100644
--- a/services/spar/spar.cabal
+++ b/services/spar/spar.cabal
@@ -80,9 +80,6 @@ library
     Spar.Sem.ScimTokenStore.Cassandra
     Spar.Sem.ScimTokenStore.Mem
     Spar.Sem.Utils
-    Spar.Sem.VerdictFormatStore
-    Spar.Sem.VerdictFormatStore.Cassandra
-    Spar.Sem.VerdictFormatStore.Mem
 
   other-modules:      Paths_spar
   hs-source-dirs:     src
diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs
index 670562d989c..aa5eac03f40 100644
--- a/services/spar/src/Spar/API.hs
+++ b/services/spar/src/Spar/API.hs
@@ -102,8 +102,6 @@ import Spar.Sem.SAMLUserStore (SAMLUserStore)
 import qualified Spar.Sem.SAMLUserStore as SAMLUserStore
 import Spar.Sem.ScimTokenStore (ScimTokenStore)
 import qualified Spar.Sem.ScimTokenStore as ScimTokenStore
-import Spar.Sem.VerdictFormatStore (VerdictFormatStore)
-import qualified Spar.Sem.VerdictFormatStore as VerdictFormatStore
 import System.Logger (Msg)
 import qualified System.Logger as Log
 import qualified URI.ByteString as URI
@@ -140,6 +138,8 @@ import qualified Wire.Sem.Logger as Logger
 import Wire.Sem.Now (Now)
 import Wire.Sem.Random (Random)
 import qualified Wire.Sem.Random as Random
+import Wire.VerdictFormatStore (VerdictFormatStore)
+import qualified Wire.VerdictFormatStore as VerdictFormatStore
 
 app :: Env -> Application
 app ctx0 req cont = do
diff --git a/services/spar/src/Spar/App.hs b/services/spar/src/Spar/App.hs
index dc1c950fd4e..a5e959ea23a 100644
--- a/services/spar/src/Spar/App.hs
+++ b/services/spar/src/Spar/App.hs
@@ -87,8 +87,6 @@ import Spar.Sem.SAMLUserStore (SAMLUserStore)
 import qualified Spar.Sem.SAMLUserStore as SAMLUserStore
 import Spar.Sem.ScimTokenStore (ScimTokenStore)
 import qualified Spar.Sem.ScimTokenStore as ScimTokenStore
-import Spar.Sem.VerdictFormatStore (VerdictFormatStore)
-import qualified Spar.Sem.VerdictFormatStore as VerdictFormatStore
 import System.Logger (Msg)
 import qualified System.Logger as Log
 import qualified System.Logger as TinyLog
@@ -115,6 +113,8 @@ import Wire.Sem.Logger (Logger)
 import qualified Wire.Sem.Logger as Logger
 import Wire.Sem.Random (Random)
 import qualified Wire.Sem.Random as Random
+import Wire.VerdictFormatStore (VerdictFormatStore)
+import qualified Wire.VerdictFormatStore as VerdictFormatStore
 
 throwSparSem :: (Member (Error SparError) r) => SparCustomError -> Sem r a
 throwSparSem = throw . SAML.CustomError
diff --git a/services/spar/src/Spar/CanonicalInterpreter.hs b/services/spar/src/Spar/CanonicalInterpreter.hs
index b2a4405b850..f8d444b801f 100644
--- a/services/spar/src/Spar/CanonicalInterpreter.hs
+++ b/services/spar/src/Spar/CanonicalInterpreter.hs
@@ -48,8 +48,6 @@ import Spar.Sem.SAMLUserStore.Cassandra (samlUserStoreToCassandra)
 import Spar.Sem.ScimTokenStore (ScimTokenStore)
 import Spar.Sem.ScimTokenStore.Cassandra (scimTokenStoreToCassandra)
 import Spar.Sem.Utils
-import Spar.Sem.VerdictFormatStore (VerdictFormatStore)
-import Spar.Sem.VerdictFormatStore.Cassandra (verdictFormatStoreToCassandra)
 import qualified System.Logger as TinyLog
 import Wire.API.Routes.Version (expandVersionExp)
 import Wire.API.User.Saml (TTLError)
@@ -84,6 +82,8 @@ import Wire.Sem.Now (Now)
 import Wire.Sem.Now.IO (nowToIO)
 import Wire.Sem.Random (Random)
 import Wire.Sem.Random.IO (randomToIO)
+import Wire.VerdictFormatStore (VerdictFormatStore)
+import Wire.VerdictFormatStore.Cassandra (verdictFormatStoreToCassandra)
 
 type CanonicalEffs =
   '[IdPSubsystem, ScimSubsystem]
diff --git a/services/spar/src/Spar/Data.hs b/services/spar/src/Spar/Data.hs
index 3daa686b6d4..2f6869f2231 100644
--- a/services/spar/src/Spar/Data.hs
+++ b/services/spar/src/Spar/Data.hs
@@ -21,7 +21,6 @@ module Spar.Data
     Env (..),
     mkEnv,
     mkTTLAssertions,
-    nominalDiffToSeconds,
     mkTTLAuthnRequests,
 
     -- * SAML Users
diff --git a/services/spar/src/Spar/Data/Instances.hs b/services/spar/src/Spar/Data/Instances.hs
index 2aec0f7602d..b8d13222bdf 100644
--- a/services/spar/src/Spar/Data/Instances.hs
+++ b/services/spar/src/Spar/Data/Instances.hs
@@ -20,16 +20,7 @@
 
 -- | 'Cql' instances for Spar types, as well as conversion functions used in "Spar.Data"
 -- (which does the actual database work).
-module Spar.Data.Instances
-  ( -- * Raw database types
-    VerdictFormatRow,
-    VerdictFormatCon (..),
-
-    -- ** Conversions
-    fromVerdictFormat,
-    toVerdictFormat,
-  )
-where
+module Spar.Data.Instances () where
 
 import Cassandra as Cas
 import Data.ByteString (toStrict)
@@ -38,36 +29,8 @@ import Data.Functor.Alt (Alt (()))
 import qualified Data.Text.Encoding as T
 import Data.Text.Encoding.Error
 import Imports
-import URI.ByteString
-import Wire.API.User.Auth
-import Wire.API.User.Saml
 import Wire.API.User.Scim
 
-type VerdictFormatRow = (VerdictFormatCon, Maybe URI, Maybe URI, Maybe CookieLabel)
-
-data VerdictFormatCon = VerdictFormatConWeb | VerdictFormatConMobile
-
-instance Cql VerdictFormatCon where
-  ctype = Tagged IntColumn
-
-  toCql VerdictFormatConWeb = CqlInt 0
-  toCql VerdictFormatConMobile = CqlInt 1
-
-  fromCql (CqlInt i) = case i of
-    0 -> pure VerdictFormatConWeb
-    1 -> pure VerdictFormatConMobile
-    n -> Left $ "unexpected VerdictFormatCon: " ++ show n
-  fromCql _ = Left "member-status: int expected"
-
-fromVerdictFormat :: VerdictFormat -> VerdictFormatRow
-fromVerdictFormat (VerdictFormatWeb mlabel) = (VerdictFormatConWeb, Nothing, Nothing, mlabel)
-fromVerdictFormat (VerdictFormatMobile succredir errredir mlabel) = (VerdictFormatConMobile, Just succredir, Just errredir, mlabel)
-
-toVerdictFormat :: VerdictFormatRow -> Maybe VerdictFormat
-toVerdictFormat (VerdictFormatConWeb, Nothing, Nothing, mlabel) = Just $ VerdictFormatWeb mlabel
-toVerdictFormat (VerdictFormatConMobile, Just succredir, Just errredir, mlabel) = Just $ VerdictFormatMobile succredir errredir mlabel
-toVerdictFormat _ = Nothing
-
 deriving instance Cql ScimToken
 
 instance Cql ScimTokenHash where
diff --git a/services/spar/test-integration/Test/Spar/DataSpec.hs b/services/spar/test-integration/Test/Spar/DataSpec.hs
index 4251a61c859..aee3e8cc2f7 100644
--- a/services/spar/test-integration/Test/Spar/DataSpec.hs
+++ b/services/spar/test-integration/Test/Spar/DataSpec.hs
@@ -35,7 +35,6 @@ import qualified Spar.Sem.AReqIDStore as AReqIDStore
 import qualified Spar.Sem.AssIDStore as AssIDStore
 import qualified Spar.Sem.SAMLUserStore as SAMLUserStore
 import qualified Spar.Sem.ScimTokenStore as ScimTokenStore
-import qualified Spar.Sem.VerdictFormatStore as VerdictFormatStore
 import Type.Reflection (typeRep)
 import URI.ByteString.QQ (uri)
 import Util.Core
@@ -46,6 +45,7 @@ import Web.Scim.Schema.Meta as Scim.Meta
 import Wire.API.User.IdentityProvider
 import Wire.API.User.Saml
 import qualified Wire.IdPConfigStore as IdPEffect
+import qualified Wire.VerdictFormatStore as VerdictFormatStore
 
 spec :: SpecWith TestEnv
 spec = do
diff --git a/services/spar/test/Test/Spar/Saml/IdPSpec.hs b/services/spar/test/Test/Spar/Saml/IdPSpec.hs
index 93f591a750c..dbd72083d34 100644
--- a/services/spar/test/Test/Spar/Saml/IdPSpec.hs
+++ b/services/spar/test/Test/Spar/Saml/IdPSpec.hs
@@ -40,7 +40,6 @@ import qualified Spar.Sem.SAMLUserStore as SAMLUserStore
 import Spar.Sem.SAMLUserStore.Mem
 import Spar.Sem.ScimTokenStore
 import Spar.Sem.ScimTokenStore.Mem
-import qualified Spar.Sem.VerdictFormatStore as VerdictFormatStore
 import System.FilePath (())
 import System.Logger (Msg)
 import System.Logger.Class (Level (..))
@@ -75,6 +74,7 @@ import Wire.Sem.Logger (discardLogs)
 import Wire.Sem.Logger.TinyLog (LogRecorder (..), newLogRecorder, recordLogs)
 import Wire.Sem.Random
 import Wire.Sem.Random.Null
+import qualified Wire.VerdictFormatStore as VerdictFormatStore
 
 spec :: Spec
 spec =
diff --git a/weeder.toml b/weeder.toml
index a738c077e87..f9e5ce6495c 100644
--- a/weeder.toml
+++ b/weeder.toml
@@ -137,7 +137,7 @@ roots = [ # may of the entries here are about general-purpose module
          "^Spar.Sem.AReqIDStore.Mem.*$", # FUTUREWORK: @fisx can we delete this?
          "^Spar.Sem.AssIDStore.Mem.*$", # FUTUREWORK: @fisx can we delete this?
          "^Spar.Sem.ScimTokenStore.Mem.*$", # FUTUREWORK: @fisx can we delete this?
-         "^Spar.Sem.VerdictFormatStore.Mem.*$", # FUTUREWORK: @fisx can we delete this?
+         "^Wire.VerdictFormatStore.Mem.*$", # FUTUREWORK: @fisx can we delete this?
          "^Spec.main$",
          "^Stern.App.runHandler$",
          "^System.Logger.Extended.runWithLogger$",

From 6a7566773c128fdcac1a6e7b04ba3486a85ece84 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Wed, 5 Aug 2026 08:48:43 +0200
Subject: [PATCH 068/113] fix: flaky testProviderSearchWhitelist test narrow
 random service name to URL-compatible chars (#5416)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

randomString in integration/test/API/Common.hs used bare randomIO
(full Unicode range, including surrogate code points U+D800-U+DFFF).
When the random service-name prefix contained a surrogate, it diverged
across two transports: Text.pack (JSON body / name path) replaced it
with U+FFFD, while URL byte percent-encoding (query prefix path)
emitted raw WTF-8 bytes. The server's Text.isPrefixOf match then
failed, filtering out all services and returning services: [].

Constrain randomString to the alphanumeric alphabet [A-Za-z0-9] via
the existing mkArray/pick idiom (matching randomName and every sibling
helper in the same file), eliminating non-scalar code points at the
source. This also closes a latent ordering flake: the old full-range
Char could emit code points below '0', which would break the
"0000000000|..." sorts-first assertion.

* fix: narrow transport-safe alphabet to the cross-transport call site

Revert the over-broad constraint on the shared `randomString` (c0cc978b)
back to its original full-range `randomIO` form, restoring original fuzz
behavior for the other callers (test passwords, `randomJSON`).

Add a dedicated `randomAlphaString` ([A-Za-z0-9]) and use it only at the
`namePrefix` call site of `testProviderSearchWhitelist` — the sole value
that crosses both transports: a JSON service-name body (`Text.pack`) and a
URL search-prefix query param (percent-encoding). Full-range `Char` can
include surrogate code points that the two transports map differently
(WTF-8 bytes vs one U+FFFD per surrogate), so the reconstructed prefix is
no longer a prefix of the stored names and the search returns 0/20
services — the flake.

Leaves `serviceSuffix` on `randomString` since it is JSON-body-only.
---
 integration/test/API/Common.hs    | 9 +++++++++
 integration/test/Test/Provider.hs | 2 +-
 2 files changed, 10 insertions(+), 1 deletion(-)

diff --git a/integration/test/API/Common.hs b/integration/test/API/Common.hs
index a22f489c9f0..cd4b4b348ab 100644
--- a/integration/test/API/Common.hs
+++ b/integration/test/API/Common.hs
@@ -76,6 +76,15 @@ randomBytes n = liftIO $ BS.pack <$> replicateM n randomIO
 randomString :: Int -> App String
 randomString n = liftIO $ replicateM n randomIO
 
+-- Transport-safe alphabet ([A-Za-z0-9]): use for values sent both as a JSON
+-- body and a URL query param. Full-range 'randomString' can emit surrogates
+-- that 'Text.pack' and percent-encoding map differently, breaking round-trips.
+randomAlphaString :: Int -> App String
+randomAlphaString n = liftIO $ replicateM n pick
+  where
+    chars = mkArray $ ['A' .. 'Z'] <> ['a' .. 'z'] <> ['0' .. '9']
+    pick = (chars !) <$> randomRIO (Array.bounds chars)
+
 randomJSON :: App Value
 randomJSON = do
   let maxThings = 5
diff --git a/integration/test/Test/Provider.hs b/integration/test/Test/Provider.hs
index a76b5ab37dd..8136dc81832 100644
--- a/integration/test/Test/Provider.hs
+++ b/integration/test/Test/Provider.hs
@@ -120,7 +120,7 @@ testProviderSearchWhitelist =
       (owner, tid, [user]) <- createTeam domain 2
       provider <- setupProvider owner def {newProviderPassword = Just defPassword}
       pid <- asString $ provider %. "id"
-      namePrefix <- randomString 10
+      namePrefix <- randomAlphaString 10
 
       services <-
         forM (taggedServiceNames namePrefix) $ \(name, tags) -> do

From 27f0dfbcba9218bba2f567a1071b3596b76d7703 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Thu, 6 Aug 2026 10:09:45 +0200
Subject: [PATCH 069/113] WPB-23631: Cache the public key bundle at startup
 instead of an effect (#5393)

---
 changelog.d/5-internal/WPB-23631-7            |  2 +
 services/brig/brig.cabal                      |  1 -
 services/brig/src/Brig/API/Public.hs          |  3 --
 services/brig/src/Brig/App.hs                 | 26 +++++++++++++
 .../brig/src/Brig/CanonicalInterpreter.hs     |  3 --
 .../brig/src/Brig/Effects/PublicKeyBundle.hs  | 37 -------------------
 services/brig/src/Brig/User/Client.hs         |  8 +---
 7 files changed, 30 insertions(+), 50 deletions(-)
 create mode 100644 changelog.d/5-internal/WPB-23631-7
 delete mode 100644 services/brig/src/Brig/Effects/PublicKeyBundle.hs

diff --git a/changelog.d/5-internal/WPB-23631-7 b/changelog.d/5-internal/WPB-23631-7
new file mode 100644
index 00000000000..4cbfcb9e847
--- /dev/null
+++ b/changelog.d/5-internal/WPB-23631-7
@@ -0,0 +1,2 @@
+Read the DPoP public key bundle once at startup and cache it, instead of
+re-reading the file on every request; the `PublicKeyBundle` effect is removed.
diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal
index ecb51c1d058..b5d59ad1dd3 100644
--- a/services/brig/brig.cabal
+++ b/services/brig/brig.cabal
@@ -110,7 +110,6 @@ library
     Brig.DeleteQueue.Interpreter
     Brig.Effects.ConnectionStore
     Brig.Effects.ConnectionStore.Cassandra
-    Brig.Effects.PublicKeyBundle
     Brig.Index.Eval
     Brig.Index.Options
     Brig.Index.Types
diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs
index 8b8d887043c..e7328564df2 100644
--- a/services/brig/src/Brig/API/Public.hs
+++ b/services/brig/src/Brig/API/Public.hs
@@ -45,7 +45,6 @@ import Brig.Calling.API qualified as Calling
 import Brig.Data.Connection qualified as Data
 import Brig.Data.Nonce as Nonce
 import Brig.Effects.ConnectionStore
-import Brig.Effects.PublicKeyBundle (PublicKeyBundle)
 import Brig.Options hiding (internalEvents)
 import Brig.Provider.API
 import Brig.Team.API qualified as Team
@@ -396,7 +395,6 @@ servantSitemap ::
     Member Now r,
     Member PasswordResetCodeStore r,
     Member PropertySubsystem r,
-    Member PublicKeyBundle r,
     Member SFT r,
     Member TinyLog r,
     Member UserKeyStore r,
@@ -881,7 +879,6 @@ createClientDPoPAccessToken ::
   forall api endpoint r.
   ( Member JwtTools r,
     Member Now r,
-    Member PublicKeyBundle r,
     IsElem endpoint api,
     HasLink endpoint,
     MkLink endpoint Link ~ (ClientId -> Link),
diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs
index 315b073524f..6c2145ea8cd 100644
--- a/services/brig/src/Brig/App.hs
+++ b/services/brig/src/Brig/App.hs
@@ -58,6 +58,7 @@ module Brig.App
     http2ManagerLens,
     extGetManagerLens,
     settingsLens,
+    publicKeyBundleLens,
     fsWatcherLens,
     turnEnvLens,
     sftEnvLens,
@@ -122,13 +123,17 @@ import Cassandra qualified as Cas
 import Cassandra.Util (initCassandraForService)
 import Control.AutoUpdate
 import Control.Error
+import Control.Exception qualified as Ex
 import Control.Lens hiding (index, (.=))
 import Control.Monad.Catch
 import Control.Monad.Trans.Resource
+import Data.ByteString qualified as BS
+import Data.ByteString.Conversion (fromByteString)
 import Data.Credentials (Credentials (..))
 import Data.Domain
 import Data.Id
 import Data.Misc
+import Data.PEMKeys (PEMKeys)
 import Data.Qualified
 import Data.Text qualified as Text
 import Data.Text.Encoding (encodeUtf8)
@@ -206,6 +211,7 @@ data Env = Env
     http2Manager :: Http2Manager,
     extGetManager :: (Manager, [Fingerprint Rsa] -> SSL.SSL -> IO ()),
     settings :: Settings,
+    publicKeyBundle :: Maybe PEMKeys,
     fsWatcher :: FS.WatchManager,
     turnEnv :: Calling.TurnEnv,
     sftEnv :: Maybe Calling.SFTEnv,
@@ -226,6 +232,24 @@ data Env = Env
 
 makeLensesWith (lensRules & lensField .~ suffixNamer) ''Env
 
+-- | Read and parse the DPoP public key bundle once at startup. Returns
+-- 'Nothing' when no path is configured or the file is missing/unparseable; in
+-- the latter case a warning is logged to aid diagnosis.
+loadPublicKeyBundle :: Logger -> Maybe FilePath -> IO (Maybe PEMKeys)
+loadPublicKeyBundle lgr path = case path of
+  Nothing -> pure Nothing
+  Just fp -> do
+    contents :: Either Ex.IOException ByteString <- Ex.try $ BS.readFile fp
+    case contents of
+      Left _ -> do
+        Log.warn lgr (Log.msg ("Failed to read DPoP public key bundle from " <> fp))
+        pure Nothing
+      Right bs -> case fromByteString bs of
+        Nothing -> do
+          Log.warn lgr (Log.msg ("Failed to parse DPoP public key bundle from " <> fp))
+          pure Nothing
+        Just keys -> pure $ Just keys
+
 newEnv :: Opts -> IO Env
 newEnv opts = do
   Just md5 <- getDigestByName "MD5"
@@ -277,6 +301,7 @@ newEnv opts = do
   rateLimitEnv <- newRateLimitEnv opts.settings.passwordHashingRateLimit
   hasqlPool <- initPostgresPool opts.postgresqlPool opts.postgresql opts.postgresqlPassword
   amqpJobsPublisherChannel <- Q.mkRabbitMqChannelMVar lgr (Just "brig") opts.rabbitmq
+  pubKeyBundle <- loadPublicKeyBundle lgr opts.settings.publicKeyBundle
   pure $!
     Env
       { cargohold = mkEndpoint $ opts.cargohold,
@@ -304,6 +329,7 @@ newEnv opts = do
         http2Manager = h2Mgr,
         extGetManager = ext,
         settings = opts.settings,
+        publicKeyBundle = pubKeyBundle,
         turnEnv = turn,
         sftEnv = mSFTEnv,
         fsWatcher = w,
diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs
index 145f799658f..f866fc5a9ca 100644
--- a/services/brig/src/Brig/CanonicalInterpreter.hs
+++ b/services/brig/src/Brig/CanonicalInterpreter.hs
@@ -22,7 +22,6 @@ import Brig.App as App
 import Brig.DeleteQueue.Interpreter as DQ
 import Brig.Effects.ConnectionStore (ConnectionStore)
 import Brig.Effects.ConnectionStore.Cassandra (connectionStoreToCassandra)
-import Brig.Effects.PublicKeyBundle
 import Brig.IO.Intra (runEvents)
 import Brig.Options (Settings (consumableNotifications), federationDomainConfigs, federationStrategy)
 import Brig.Options qualified as Opt
@@ -261,7 +260,6 @@ type BrigLowerLevelEffects =
      GundeckAPIAccess,
      FederationConfigStore,
      Jwk,
-     PublicKeyBundle,
      JwtTools,
      BlockListStore,
      BudgetStore,
@@ -451,7 +449,6 @@ runBrigToIO e (AppT ma) = do
               . budgetStoreToCassandra @Cas.Client
               . interpretBlockListStoreToCassandra e.casClient
               . interpretJwtTools
-              . interpretPublicKeyBundle
               . interpretJwk
               . interpretFederationDomainConfig e.casClient e.settings.federationStrategy (foldMap (remotesMapFromCfgFile . fmap (.federationDomainConfig)) e.settings.federationDomainConfigs)
               . runGundeckAPIAccess e.gundeckEndpoint
diff --git a/services/brig/src/Brig/Effects/PublicKeyBundle.hs b/services/brig/src/Brig/Effects/PublicKeyBundle.hs
deleted file mode 100644
index 5b8775c4d54..00000000000
--- a/services/brig/src/Brig/Effects/PublicKeyBundle.hs
+++ /dev/null
@@ -1,37 +0,0 @@
-{-# LANGUAGE TemplateHaskell #-}
-
--- This file is part of the Wire Server implementation.
---
--- Copyright (C) 2025 Wire Swiss GmbH 
---
--- This program is free software: you can redistribute it and/or modify it under
--- the terms of the GNU Affero General Public License as published by the Free
--- Software Foundation, either version 3 of the License, or (at your option) any
--- later version.
---
--- This program is distributed in the hope that it will be useful, but WITHOUT
--- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
--- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
--- details.
---
--- You should have received a copy of the GNU Affero General Public License along
--- with this program. If not, see .
-
-module Brig.Effects.PublicKeyBundle where
-
-import Control.Exception
-import Data.ByteString qualified as BS
-import Data.ByteString.Conversion
-import Data.PEMKeys
-import Imports
-import Polysemy
-
-data PublicKeyBundle m a where
-  Get :: FilePath -> PublicKeyBundle m (Maybe PEMKeys)
-
-makeSem ''PublicKeyBundle
-
-interpretPublicKeyBundle :: (Member (Embed IO) r) => Sem (PublicKeyBundle ': r) a -> Sem r a
-interpretPublicKeyBundle = interpret $ \(Get fp) -> do
-  contents :: Either IOException ByteString <- liftIO $ try $ BS.readFile fp
-  pure $ either (const Nothing) fromByteString contents
diff --git a/services/brig/src/Brig/User/Client.hs b/services/brig/src/Brig/User/Client.hs
index 372afa752fd..7cc44a042cf 100644
--- a/services/brig/src/Brig/User/Client.hs
+++ b/services/brig/src/Brig/User/Client.hs
@@ -22,8 +22,6 @@ where
 
 import Brig.App
 import Brig.Data.Nonce as Nonce
-import Brig.Effects.PublicKeyBundle (PublicKeyBundle)
-import Brig.Effects.PublicKeyBundle qualified as PublicKeyBundle
 import Brig.Options qualified as Opt
 import Control.Error
 import Control.Monad.Trans.Except (except)
@@ -52,7 +50,7 @@ import Wire.UserSubsystem (UserSubsystem)
 import Wire.UserSubsystem qualified as User
 
 createClientDPoPAccessToken ::
-  (Member JwtTools r, Member Now r, Member PublicKeyBundle r, Member UserSubsystem r) =>
+  (Member JwtTools r, Member Now r, Member UserSubsystem r) =>
   Local UserId ->
   ClientId ->
   StdMethod ->
@@ -92,9 +90,7 @@ createClientDPoPAccessToken luid cid method link proof = do
   expiresIn <- Opt.dpopTokenExpirationTimeSecs <$> asks (.settings)
   now <- fromUTCTime <$> lift (liftSem Now.get)
   let expiresAt = now & addToEpoch expiresIn
-  pubKeyBundle <- do
-    pathToKeys <- ExceptT (note KeyBundleError <$> asks (.settings.publicKeyBundle))
-    ExceptT $ note KeyBundleError <$> liftSem (PublicKeyBundle.get pathToKeys)
+  pubKeyBundle <- ExceptT (note KeyBundleError <$> asks (.publicKeyBundle))
   token <-
     ExceptT $
       liftSem $

From 50a6173edd4b7b566b615b1ac70d60f442553713 Mon Sep 17 00:00:00 2001
From: Leif Battermann 
Date: Thu, 6 Aug 2026 12:10:56 +0200
Subject: [PATCH 070/113] WPB-23177 [fix] duplicate user accounts created after
 expired SCIM invitation and manual re-invite (#5400)

---
 cassandra-schema.cql                          |  24 ++
 changelog.d/2-features/WPB-23177              |   1 +
 changelog.d/3-bug-fixes/WPB-23177             |   1 +
 integration/test/API/BrigInternal.hs          |  10 +
 integration/test/Test/Spar.hs                 | 105 ++++++
 libs/types-common/src/Data/Id.hs              |   5 +
 .../src/Wire/InvitationStore.hs               |   3 +
 .../src/Wire/InvitationStore/Cassandra.hs     |  33 ++
 .../TeamInvitationSubsystem/Interpreter.hs    |  73 +++-
 .../src/Wire/UserSubsystem/Interpreter.hs     |   2 +
 .../test/unit/Wire/MiniBackend.hs             |   9 +
 .../Wire/MockInterpreters/InvitationStore.hs  |  25 +-
 .../unit/Wire/MockInterpreters/UserStore.hs   |  15 +-
 .../InterpreterSpec.hs                        | 331 +++++++++++++++++-
 services/brig/brig.cabal                      |   1 +
 services/brig/src/Brig/API/Internal.hs        |   3 +-
 services/brig/src/Brig/API/Public.hs          |   2 +
 services/brig/src/Brig/API/User.hs            |  12 +-
 services/brig/src/Brig/Data/User.hs           |   5 -
 .../brig/src/Brig/InternalEvent/Process.hs    |   2 +
 services/brig/src/Brig/Schema/Run.hs          |   4 +-
 .../Schema/V93_AddScimPendingUserEmail.hs     |  41 +++
 services/brig/src/Brig/Team/API.hs            |   2 +-
 23 files changed, 688 insertions(+), 21 deletions(-)
 create mode 100644 changelog.d/2-features/WPB-23177
 create mode 100644 changelog.d/3-bug-fixes/WPB-23177
 create mode 100644 services/brig/src/Brig/Schema/V93_AddScimPendingUserEmail.hs

diff --git a/cassandra-schema.cql b/cassandra-schema.cql
index 7d2e47e16e0..c79723f599c 100644
--- a/cassandra-schema.cql
+++ b/cassandra-schema.cql
@@ -1005,6 +1005,30 @@ CREATE TABLE brig_test.team_invitation_info (
     AND read_repair = 'BLOCKING'
     AND speculative_retry = '99p';
 
+CREATE TABLE brig_test.team_scim_pending_user_email (
+    team uuid,
+    email text,
+    user uuid,
+    PRIMARY KEY ((team, email), user)
+) WITH CLUSTERING ORDER BY (user ASC)
+    AND additional_write_policy = '99p'
+    AND bloom_filter_fp_chance = 0.01
+    AND caching = {'keys': 'ALL', 'rows_per_partition': 'NONE'}
+    AND cdc = false
+    AND comment = ''
+    AND compaction = {'class': 'org.apache.cassandra.db.compaction.SizeTieredCompactionStrategy', 'max_threshold': '32', 'min_threshold': '4'}
+    AND compression = {'chunk_length_in_kb': '16', 'class': 'org.apache.cassandra.io.compress.LZ4Compressor'}
+    AND memtable = 'default'
+    AND crc_check_chance = 1.0
+    AND default_time_to_live = 0
+    AND extensions = {}
+    AND gc_grace_seconds = 864000
+    AND max_index_interval = 2048
+    AND memtable_flush_period_in_ms = 0
+    AND min_index_interval = 128
+    AND read_repair = 'BLOCKING'
+    AND speculative_retry = '99p';
+
 CREATE TABLE brig_test.unique_claims (
     value text PRIMARY KEY,
     claims set
diff --git a/changelog.d/2-features/WPB-23177 b/changelog.d/2-features/WPB-23177
new file mode 100644
index 00000000000..492ac9a01d9
--- /dev/null
+++ b/changelog.d/2-features/WPB-23177
@@ -0,0 +1 @@
+Manual team invitations now conflict when a matching pending SCIM invitation already exists for the same team and email address.
diff --git a/changelog.d/3-bug-fixes/WPB-23177 b/changelog.d/3-bug-fixes/WPB-23177
new file mode 100644
index 00000000000..b52e4bd670b
--- /dev/null
+++ b/changelog.d/3-bug-fixes/WPB-23177
@@ -0,0 +1 @@
+Release a handle claimed after a SCIM invitation expired, before cleanup, preventing a subsequent team invitation from using that handle.
diff --git a/integration/test/API/BrigInternal.hs b/integration/test/API/BrigInternal.hs
index 4a0051e8907..4407e1a043b 100644
--- a/integration/test/API/BrigInternal.hs
+++ b/integration/test/API/BrigInternal.hs
@@ -76,6 +76,16 @@ getUsersId domain ids = do
   req <- baseRequest domain Brig Unversioned "/i/users"
   submit "GET" $ req & addQueryParams [("ids", intercalate "," ids)]
 
+getUsersIdIncludingPending :: (HasCallStack, MakesValue domain) => domain -> [String] -> App Response
+getUsersIdIncludingPending domain ids = do
+  req <- baseRequest domain Brig Unversioned "/i/users"
+  submit "GET" $
+    req
+      & addQueryParams
+        [ ("ids", intercalate "," ids),
+          ("includePendingInvitations", "true")
+        ]
+
 getUsersByEmail :: (HasCallStack, MakesValue domain) => domain -> [String] -> App Response
 getUsersByEmail domain emails = do
   req <- baseRequest domain Brig Unversioned "/i/users"
diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs
index fff111393d5..cd70ea77b27 100644
--- a/integration/test/Test/Spar.hs
+++ b/integration/test/Test/Spar.hs
@@ -43,6 +43,7 @@ import qualified SAML2.WebSSO.Test.MockResponse as SAML
 import qualified SAML2.WebSSO.Test.Util as SAML
 import qualified SAML2.WebSSO.XML as SAMLXML
 import SetupHelpers
+import Testlib.Assertions
 import Testlib.JSON
 import Testlib.PTest
 import Testlib.Prelude
@@ -52,6 +53,110 @@ import qualified Time.System as Hourglass
 ----------------------------------------------------------------------
 -- scim stuff
 
+testTeamInvitationWhenScimInvitationExpired :: (HasCallStack) => App ()
+testTeamInvitationWhenScimInvitationExpired = do
+  let settings =
+        def
+          { brigCfg =
+              -- timeout for both SCIM and team invitations
+              setField "optSettings.setTeamInvitationTimeout" (2 :: Int)
+                -- Controls when asynchronous cleanup removes expired SCIM pending accounts.
+                . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int)
+          }
+  withModifiedBackend settings $ \domain -> do
+    (owner, _tid, _) <- createTeam domain 1
+    token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString
+
+    -- Create a SCIM user and let its invitation expire. Cleanup is deliberately
+    -- delayed so the expired pending account still exists at this point.
+    email <- randomEmail
+    externalId <- randomExternalId
+    scimUser <- randomScimUserWithEmail externalId email
+    scid <- createScimUser domain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+    handle <- scimUser %. "userName" >>= asString
+
+    -- assert that the SCIM handle is claimed
+    putHandle owner handle >>= assertStatus 409
+
+    -- Wait until the SCIM invitation has expired.
+    eventually $ getInvitationByEmail domain email >>= assertStatus 404
+
+    -- Create and accept a manual team invitation for the same email. This is
+    -- expected to succeed after the expired SCIM account has been cleaned up.
+    invitation <- postInvitation owner (def {email = Just email}) >>= getJSON 201
+    code <- getInvitationCode owner invitation >>= getJSON 200 >>= (%. "code") >>= asString
+    registerUserWith domain email code "Alice" >>= assertStatus 201
+    user <- getUsersByEmail domain [email] >>= getJSON 200 >>= asList >>= assertOne
+    manualUserId <- user %. "id" >>= asString
+
+    -- The handle previously held by the SCIM account is available again.
+    putHandle user handle >>= assertSuccess
+
+    manualUser <- getUsersId domain [manualUserId] >>= getJSON 200 >>= asList >>= assertOne
+    manualUser %. "id" `shouldMatch` manualUserId
+    manualUser %. "email" `shouldMatch` email
+    manualUser %. "handle" `shouldMatch` handle
+    manualUser %. "managed_by" `shouldMatch` "wire"
+    manualUser %. "status" `shouldMatch` "active"
+
+    -- The regular internal users API filters deleted records, so it cannot
+    -- distinguish a deleted SCIM account from an account that is not found.
+    shouldBeEmpty $ getUsersId domain [scid] >>= getJSON 200 >>= asList
+
+testTeamInvitationWhenScimInvitationPending :: (HasCallStack) => App ()
+testTeamInvitationWhenScimInvitationPending = do
+  (owner, _tid, _) <- createTeam OwnDomain 1
+  (otherOwner, _otherTid, _) <- createTeam OwnDomain 1
+  token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString
+
+  -- Create a SCIM user; this sends a SCIM invitation that remains pending.
+  email <- randomEmail
+  externalId <- randomExternalId
+  scimUser <- randomScimUserWithEmail externalId email
+  scid <- createScimUser OwnDomain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+  handle <- scimUser %. "userName" >>= asString
+
+  -- The SCIM invitation is still pending. A second team invitation for the
+  -- same email and team must be rejected with a conflict.
+  postInvitation owner (def {email = Just email}) >>= assertStatus 409
+
+  -- The email must still be invit-able by a different team; otherwise a
+  -- pending SCIM invitation could be used for an email-registration DoS.
+  postInvitation otherOwner (def {email = Just email}) >>= assertStatus 201
+
+  users <- getUsersIdIncludingPending OwnDomain [scid] >>= getJSON 200 >>= asList
+  user <- assertOne users
+  user %. "email" `shouldMatch` email
+  user %. "handle" `shouldMatch` handle
+  user %. "managed_by" `shouldMatch` "scim"
+  user %. "status" `shouldMatch` "pending-invitation"
+
+testTeamInvitationWhenScimAccountExists :: (HasCallStack) => App ()
+testTeamInvitationWhenScimAccountExists = do
+  (owner, tid, _) <- createTeam OwnDomain 1
+  token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString
+
+  -- Create a SCIM user and accept the resulting SCIM invitation below.
+  email <- randomEmail
+  externalId <- randomExternalId
+  scimUser <- randomScimUserWithEmail externalId email
+  scid <- createScimUser OwnDomain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+  handle <- scimUser %. "userName" >>= asString
+
+  -- Accept the SCIM invitation so the SCIM-managed account is active.
+  registerInvitedUser OwnDomain tid email
+
+  -- An active SCIM account already owns the email. A second team invitation
+  -- for the same email must therefore be rejected with a conflict.
+  postInvitation owner (def {email = Just email}) >>= assertStatus 409
+
+  users <- getUsersId OwnDomain [scid] >>= getJSON 200 >>= asList
+  user <- assertOne users
+  user %. "email" `shouldMatch` email
+  user %. "handle" `shouldMatch` handle
+  user %. "managed_by" `shouldMatch` "scim"
+  user %. "status" `shouldMatch` "active"
+
 testSparUserCreationInvitationTimeout :: (HasCallStack) => App ()
 testSparUserCreationInvitationTimeout = do
   (owner, tid, _) <- createTeam OwnDomain 1
diff --git a/libs/types-common/src/Data/Id.hs b/libs/types-common/src/Data/Id.hs
index a1c0aedf911..d9d7156640e 100644
--- a/libs/types-common/src/Data/Id.hs
+++ b/libs/types-common/src/Data/Id.hs
@@ -41,6 +41,7 @@ module Data.Id
     parseIdFromText,
     idToText,
     idToString,
+    invitationIdToUserId,
     idObjectSchema,
     IdObject (..),
 
@@ -102,6 +103,10 @@ import System.Logger (ToBytes)
 import Test.QuickCheck
 import Test.QuickCheck.Instances ()
 
+-- | Pending invitation users reuse the invitation UUID as the user UUID.
+invitationIdToUserId :: InvitationId -> UserId
+invitationIdToUserId = Id . toUUID
+
 data IdTag
   = Asset
   | Conversation
diff --git a/libs/wire-subsystems/src/Wire/InvitationStore.hs b/libs/wire-subsystems/src/Wire/InvitationStore.hs
index 54873c400f0..e875677cce5 100644
--- a/libs/wire-subsystems/src/Wire/InvitationStore.hs
+++ b/libs/wire-subsystems/src/Wire/InvitationStore.hs
@@ -96,6 +96,9 @@ data InvitationStore :: Effect where
   LookupInvitation :: TeamId -> InvitationId -> InvitationStore m (Maybe StoredInvitation)
   LookupInvitationByCode :: InvitationCode -> InvitationStore m (Maybe StoredInvitation)
   LookupInvitationsByEmail :: EmailAddress -> InvitationStore m [StoredInvitation]
+  InsertPendingScimUser :: TeamId -> EmailAddress -> UserId -> InvitationStore m ()
+  LookupPendingScimUsers :: TeamId -> EmailAddress -> InvitationStore m [UserId]
+  DeletePendingScimUser :: TeamId -> EmailAddress -> UserId -> InvitationStore m ()
   -- | Range is page size, it defaults to 100
   LookupInvitationsPaginated :: Maybe (Range 1 500 Int32) -> TeamId -> Maybe InvitationId -> InvitationStore m (PaginatedResult [StoredInvitation])
   CountInvitations :: TeamId -> InvitationStore m Int64
diff --git a/libs/wire-subsystems/src/Wire/InvitationStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/InvitationStore/Cassandra.hs
index e3ecf2d63bb..fbafe1e291a 100644
--- a/libs/wire-subsystems/src/Wire/InvitationStore/Cassandra.hs
+++ b/libs/wire-subsystems/src/Wire/InvitationStore/Cassandra.hs
@@ -44,6 +44,9 @@ interpretInvitationStoreToCassandra casClient =
       InsertInvitation newInv timeout -> embed $ insertInvitationImpl newInv timeout
       LookupInvitation tid iid -> embed $ lookupInvitationImpl tid iid
       LookupInvitationsByEmail email -> embed $ lookupInvitationsByEmailImpl email
+      InsertPendingScimUser tid email uid -> embed $ insertPendingScimUserImpl tid email uid
+      LookupPendingScimUsers tid email -> embed $ lookupPendingScimUsersImpl tid email
+      DeletePendingScimUser tid email uid -> embed $ deletePendingScimUserImpl tid email uid
       LookupInvitationByCode code -> embed $ lookupInvitationByCodeImpl code
       LookupInvitationsPaginated mSize tid miid -> embed $ lookupInvitationsPaginatedImpl mSize tid miid
       CountInvitations tid -> embed $ countInvitationsImpl tid
@@ -152,6 +155,36 @@ lookupInvitationsByEmailImpl email = do
       SELECT team, role, id, created_at, created_by, email, name, code FROM team_invitation WHERE team = ? AND id = ?
       |]
 
+insertPendingScimUserImpl :: TeamId -> EmailAddress -> UserId -> Client ()
+insertPendingScimUserImpl team email uid =
+  retry x5 $ write cql (params LocalQuorum (team, email, uid))
+  where
+    cql :: PrepQuery W (TeamId, EmailAddress, UserId) ()
+    cql =
+      [sql|
+        INSERT INTO team_scim_pending_user_email (team, email, user) VALUES (?, ?, ?)
+      |]
+
+lookupPendingScimUsersImpl :: TeamId -> EmailAddress -> Client [UserId]
+lookupPendingScimUsersImpl team email =
+  map runIdentity <$> retry x1 (query cql (params LocalQuorum (team, email)))
+  where
+    cql :: PrepQuery R (TeamId, EmailAddress) (Identity UserId)
+    cql =
+      [sql|
+        SELECT user FROM team_scim_pending_user_email WHERE team = ? AND email = ?
+      |]
+
+deletePendingScimUserImpl :: TeamId -> EmailAddress -> UserId -> Client ()
+deletePendingScimUserImpl team email uid =
+  retry x5 $ write cql (params LocalQuorum (team, email, uid))
+  where
+    cql :: PrepQuery W (TeamId, EmailAddress, UserId) ()
+    cql =
+      [sql|
+        DELETE FROM team_scim_pending_user_email WHERE team = ? AND email = ? AND user = ?
+      |]
+
 lookupInvitationImpl :: TeamId -> InvitationId -> Client (Maybe StoredInvitation)
 lookupInvitationImpl tid iid =
   fmap asRecord
diff --git a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs
index d7e7b9e4682..82002248f68 100644
--- a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs
+++ b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs
@@ -54,11 +54,14 @@ import Wire.Sem.Now (Now)
 import Wire.Sem.Now qualified as Now
 import Wire.Sem.Random (Random)
 import Wire.Sem.Random qualified as Random
+import Wire.StoredUser (StoredUser (email, managedBy, status, teamId))
 import Wire.TeamInvitationSubsystem
 import Wire.TeamInvitationSubsystem.Error
 import Wire.TeamSubsystem
 import Wire.UserKeyStore
-import Wire.UserSubsystem (UserSubsystem, getLocalUserAccountByUserKey, getSelfProfile, isBlocked)
+import Wire.UserStore (UserStore)
+import Wire.UserStore qualified as UserStore
+import Wire.UserSubsystem (UserSubsystem, getAccountNoFilter, getLocalUserAccountByUserKey, getSelfProfile, isBlocked)
 
 data TeamInvitationSubsystemConfig = TeamInvitationSubsystemConfig
   { maxTeamSize :: Word32,
@@ -75,6 +78,7 @@ runTeamInvitationSubsystem ::
     Member UserSubsystem r,
     Member Random r,
     Member InvitationStore r,
+    Member UserStore r,
     Member Now r,
     Member EmailSubsystem r,
     Member EnterpriseLoginSubsystem r,
@@ -88,6 +92,12 @@ runTeamInvitationSubsystem cfg = interpret $ \case
   InternalCreateInvitation tid mExpectedInvId role mbInviterUid inviterEmail invRequest ->
     runInputConst cfg $ createInvitation' tid mExpectedInvId role mbInviterUid inviterEmail invRequest
 
+data ScimInvitationState
+  = ScimInvitationConflict
+  | ScimInvitationExpired UserId
+  | ScimInvitationStale UserId
+  deriving (Eq, Show)
+
 inviteUserImpl ::
   ( Member (Error TeamInvitationSubsystemError) r,
     Member GalleyAPIAccess r,
@@ -100,7 +110,8 @@ inviteUserImpl ::
     Member EmailSubsystem r,
     Member EnterpriseLoginSubsystem r,
     Member TeamSubsystem r,
-    Member UserKeyStore r
+    Member UserKeyStore r,
+    Member UserStore r
   ) =>
   Local UserId ->
   TeamId ->
@@ -111,6 +122,7 @@ inviteUserImpl luid tid request = do
 
   let inviteePerms = Teams.rolePermissions inviteeRole
   ensurePermissionToAddUser (tUnqualified luid) tid inviteePerms
+  reconcileScimInvitation request.inviteeEmail
 
   inviterEmail <-
     note TeamInvitationNoEmail =<< runMaybeT do
@@ -131,6 +143,63 @@ inviteUserImpl luid tid request = do
     loc inv =
       InvitationLocation $ "/teams/" <> toByteString' tid <> "/invitations/" <> toByteString' inv.invitationId
 
+    reconcileScimInvitation email = do
+      pendingScimUsers <- Store.lookupPendingScimUsers tid email
+      invitations <-
+        if null pendingScimUsers
+          then pure []
+          else Store.lookupInvitationsByEmail email
+      invitationStates <- traverse (classifyScimUser email invitations) pendingScimUsers
+
+      for_ invitationStates $ \case
+        ScimInvitationExpired uid -> cleanupExpiredScimUser email uid
+        ScimInvitationStale uid -> Store.deletePendingScimUser tid email uid
+        ScimInvitationConflict -> pure ()
+
+      when (ScimInvitationConflict `elem` invitationStates) $
+        throw TeamInvitationEmailTaken
+
+    classifyScimUser requestedEmail invitations uid = do
+      mStoredUser <- UserStore.getUser uid
+      pure $ case mStoredUser of
+        Nothing -> ScimInvitationStale uid
+        Just storedUser
+          | storedUser.teamId /= Just tid
+              || storedUser.email /= Just requestedEmail
+              || storedUser.managedBy /= Just ManagedByScim ->
+              ScimInvitationStale uid
+          | otherwise ->
+              case storedUser.status of
+                Just PendingInvitation ->
+                  if (not (any (invitationIsLive uid) invitations))
+                    then
+                      -- Only a matching pending SCIM account can be cleaned
+                      -- up when its invitation has expired.
+                      ScimInvitationExpired uid
+                    else
+                      -- The SCIM invitation is still usable, so the existing
+                      -- pending account must not be deleted or replaced.
+                      ScimInvitationConflict
+                _ ->
+                  -- An active SCIM account must continue to block a manual
+                  -- invitation, even if the index was not removed on activation.
+                  ScimInvitationConflict
+
+    invitationIsLive uid inv =
+      inv.teamId == tid
+        && invitationIdToUserId inv.invitationId == uid
+
+    cleanupExpiredScimUser requestedEmail uid = do
+      -- Delete the account synchronously so UserStore releases its handle before
+      -- the manual invitation is created. Keep the index entry if deletion fails.
+      mUser <- getAccountNoFilter (qualifyAs luid uid)
+      case mUser of
+        Nothing -> pure ()
+        Just user -> do
+          UserStore.deleteUser user
+          deleteKeyForUser uid (mkEmailKey requestedEmail)
+      Store.deletePendingScimUser tid requestedEmail uid
+
 createInvitation' ::
   ( Member GalleyAPIAccess r,
     Member UserSubsystem r,
diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs
index 7873ab84b3e..d5cb2dfee62 100644
--- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs
+++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs
@@ -1206,6 +1206,8 @@ acceptTeamInvitationImpl luid pw code = do
   unless added $ throw UserSubsystemTooManyTeamMembers
   updateUserTeam uid tid
   deleteInvitation inv.teamId inv.invitationId
+  for_ (userEmail . selfUser =<< mSelfProfile) $ \email ->
+    deletePendingScimUser tid email uid
   syncUserIndex uid
   generateUserEvent uid Nothing (teamUpdated uid tid)
 
diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs
index 1aa9f8bdf01..1324d919db3 100644
--- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs
+++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs
@@ -392,6 +392,7 @@ type StateEffects =
      State (Map (TeamId) [TeamCollaborator]),
      State (Map (TeamId, InvitationId) StoredInvitation),
      State (Map InvitationCode StoredInvitation),
+     State (Map (TeamId, EmailAddress) [UserId]),
      State (Map EmailKey (Maybe UserId, ActivationCode)),
      State [EmailKey],
      State [StoredUser],
@@ -422,6 +423,7 @@ stateEffectsInterpreters MiniBackendParams {..} =
     . liftUserStoreState
     . liftBlockListStoreState
     . liftActivationCodeStoreState
+    . liftPendingScimUserStoreState
     . liftInvitationInfoStoreState
     . liftInvitationStoreState
     . liftTeamCollaboratorsStoreState
@@ -515,6 +517,7 @@ data MiniBackend = MkMiniBackend
     activationCodes :: Map EmailKey (Maybe UserId, ActivationCode),
     invitationInfos :: Map InvitationCode StoredInvitation,
     invitations :: Map (TeamId, InvitationId) StoredInvitation,
+    pendingScimUsers :: Map (TeamId, EmailAddress) [UserId],
     teamIdps :: Map TeamId IdPList,
     teamCollaborators :: Map TeamId [TeamCollaborator],
     pushNotifications :: [Push],
@@ -535,6 +538,7 @@ instance Default MiniBackend where
         activationCodes = mempty,
         invitationInfos = mempty,
         invitations = mempty,
+        pendingScimUsers = mempty,
         teamIdps = mempty,
         teamCollaborators = mempty,
         pushNotifications = mempty,
@@ -818,6 +822,11 @@ liftInvitationStoreState = interpret \case
   Polysemy.State.Get -> gets (.invitations)
   Put newInvs -> modify $ \b -> b {invitations = newInvs}
 
+liftPendingScimUserStoreState :: (Member (State MiniBackend) r) => Sem (State (Map (TeamId, EmailAddress) [UserId]) : r) a -> Sem r a
+liftPendingScimUserStoreState = interpret \case
+  Polysemy.State.Get -> gets (.pendingScimUsers)
+  Put newUsers -> modify $ \b -> b {pendingScimUsers = newUsers}
+
 liftTeamCollaboratorsStoreState :: (Member (State MiniBackend) r) => Sem (State (Map TeamId [TeamCollaborator]) : r) a -> Sem r a
 liftTeamCollaboratorsStoreState = interpret \case
   Polysemy.State.Get -> gets (.teamCollaborators)
diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/InvitationStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/InvitationStore.hs
index 0f7b6f7aec7..7bc540116f4 100644
--- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/InvitationStore.hs
+++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/InvitationStore.hs
@@ -17,19 +17,20 @@
 
 module Wire.MockInterpreters.InvitationStore where
 
-import Data.Id (InvitationId, TeamId)
+import Data.Id (InvitationId, TeamId, UserId)
 import Data.Map (alter, elems, (!?))
 import Data.Map qualified as M
 import Imports hiding ((!?))
 import Polysemy
 import Polysemy.State (State, get, gets, modify)
-import Wire.API.User (InvitationCode (..))
+import Wire.API.User (EmailAddress, InvitationCode (..))
 import Wire.InvitationStore
 
 inMemoryInvitationStoreInterpreter ::
   forall r.
   ( Member (State (Map (TeamId, InvitationId) StoredInvitation)) r,
-    Member (State (Map (InvitationCode) StoredInvitation)) r
+    Member (State (Map (InvitationCode) StoredInvitation)) r,
+    Member (State (Map (TeamId, EmailAddress) [UserId])) r
   ) =>
   InterpreterFor InvitationStore r
 inMemoryInvitationStoreInterpreter = interpret \case
@@ -49,7 +50,23 @@ inMemoryInvitationStoreInterpreter = interpret \case
   LookupInvitationsByEmail em ->
     let c i = guard (i.email == em) $> i
      in mapMaybe c . elems <$> get @(Map (TeamId, InvitationId) _)
+  InsertPendingScimUser tid email uid ->
+    modify @(Map (TeamId, EmailAddress) [UserId]) (M.insertWith (++) (tid, email) [uid])
+  LookupPendingScimUsers tid email ->
+    gets @(Map (TeamId, EmailAddress) [UserId]) (fromMaybe [] . (!? (tid, email)))
+  DeletePendingScimUser tid email uid ->
+    modify @(Map (TeamId, EmailAddress) [UserId]) $
+      M.alter
+        ( \case
+            Nothing -> Nothing
+            Just uids -> case filter (/= uid) uids of
+              [] -> Nothing
+              remaining -> Just remaining
+        )
+        (tid, email)
   LookupInvitationsPaginated {} -> error "LookupInvitationsPaginated"
-  CountInvitations tid -> gets (fromIntegral . M.size . M.filterWithKey (\(tid', _) _v -> tid == tid'))
+  CountInvitations tid ->
+    gets @(Map (TeamId, InvitationId) StoredInvitation)
+      (fromIntegral . M.size . M.filterWithKey (\(tid', _) _v -> tid == tid'))
   DeleteInvitation _tid _invId -> error "DeleteInvitation"
   DeleteAllTeamInvitations _tid -> error "DeleteAllTeamInvitations"
diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs
index 8cedb9a6584..d30c8546589 100644
--- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs
+++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs
@@ -51,7 +51,16 @@ inMemoryUserStoreInterpreter ::
     Member (State (Map UserId Password)) r
   ) =>
   InterpreterFor UserStore r
-inMemoryUserStoreInterpreter = interpret $ \case
+inMemoryUserStoreInterpreter = inMemoryUserStoreInterpreterWithDeleteHook (const $ pure ())
+
+inMemoryUserStoreInterpreterWithDeleteHook ::
+  forall r.
+  ( Member (State [StoredUser]) r,
+    Member (State (Map UserId Password)) r
+  ) =>
+  (UserId -> Sem r ()) ->
+  InterpreterFor UserStore r
+inMemoryUserStoreInterpreterWithDeleteHook onDelete = interpret $ \case
   CreateUser new _ -> do
     modify (newStoredUserToStoredUser new :)
     forM_ new.password $ modify . Map.insert new.id
@@ -127,7 +136,9 @@ inMemoryUserStoreInterpreter = interpret $ \case
         us <- get
         us' <- f us
         put us'
-  DeleteUser user -> modify @[StoredUser] $ filter (\u -> u.id /= User.userId user)
+  DeleteUser user -> do
+    onDelete (User.userId user)
+    modify @[StoredUser] $ filter (\u -> u.id /= User.userId user)
   LookupName uid -> (.name) <$$> gets @[StoredUser] (find $ \u -> u.id == uid)
   LookupHandle h -> lookupHandleImpl h
   GlimpseHandle h -> lookupHandleImpl h
diff --git a/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs
index ce135460d02..ef2f85d7657 100644
--- a/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs
+++ b/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs
@@ -41,9 +41,11 @@ import Test.QuickCheck
 import Wire.API.EnterpriseLogin
 import Wire.API.Error (ErrorS)
 import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound))
+import Wire.API.Password (Password)
 import Wire.API.Team.Invitation
 import Wire.API.Team.Member
 import Wire.API.Team.Permission
+import Wire.API.Team.Role (defaultRole)
 import Wire.API.User
 import Wire.EmailSubsystem
 import Wire.EnterpriseLoginSubsystem
@@ -61,6 +63,7 @@ import Wire.TeamSubsystem
 import Wire.TeamSubsystem.GalleyAPI
 import Wire.UserKeyStore
 import Wire.UserStore (UserStore)
+import Wire.UserStore qualified as UserStore
 import Wire.UserSubsystem
 import Wire.Util
 
@@ -75,6 +78,7 @@ type AllEffects =
     UserKeyStore,
     State (Map (TeamId, InvitationId) StoredInvitation),
     State (Map (InvitationCode) StoredInvitation),
+    State (Map (TeamId, EmailAddress) [UserId]),
     Now,
     State UTCTime,
     Error TeamInvitationSubsystemError,
@@ -84,6 +88,7 @@ type AllEffects =
     State (Map EmailAddress [SentMail]),
     UserSubsystem,
     UserStore,
+    State [UserId],
     UserKeyStore
   ]
 
@@ -94,11 +99,42 @@ data RunAllEffectsArgs = RunAllEffectsArgs
   }
   deriving (Eq, Show)
 
+data InviteScenarioObservation = InviteScenarioObservation
+  { -- 'Nothing' means the manual invitation was created successfully.
+    invitationResult :: Maybe TeamInvitationSubsystemError,
+    -- User IDs passed to 'UserStore.DeleteUser' during reconciliation.
+    deletedUserIds :: [UserId],
+    -- The candidate user's record after reconciliation, if it still exists.
+    observedUser :: Maybe StoredUser,
+    -- User IDs still present in the pending SCIM index after reconciliation.
+    observedPendingScimUsers :: [UserId]
+  }
+  deriving (Eq, Show)
+
+data InviteScenarioInput = InviteScenarioInput
+  { invitationTeam :: TeamId,
+    inviter :: StoredUser,
+    otherUsers :: [StoredUser],
+    pendingScimUsers :: [(TeamId, EmailAddress, UserId)],
+    liveInvitations :: [InsertInvitation],
+    inviteeEmail :: EmailAddress,
+    observedUid :: UserId
+  }
+  deriving (Eq, Show)
+
 runAllEffects :: RunAllEffectsArgs -> Sem AllEffects a -> Either LocalErrors a
-runAllEffects args =
+runAllEffects args = runAllEffectsWithUserKeys args.initialUsers args
+
+runAllEffectsWithUserKeys :: [StoredUser] -> RunAllEffectsArgs -> Sem AllEffects a -> Either LocalErrors a
+runAllEffectsWithUserKeys initialUsers args =
   run
-    . runInMemoryUserKeyStoreIntepreterWithStoredUsers args.initialUsers
-    . runInMemoryUserStoreInterpreter args.initialUsers mempty
+    . runInMemoryUserKeyStoreIntepreterWithStoredUsers initialUsers
+    . evalState ([] :: [UserId])
+    . evalState mempty
+    . evalState args.initialUsers
+    . inMemoryUserStoreInterpreterWithDeleteHook (\uid -> modify @[UserId] (uid :))
+    . raiseUnder @(State [StoredUser])
+    . raiseUnder @(State (Map UserId Password))
     . inMemoryUserSubsystemInterpreter
     . evalState mempty
     . noopEmailSubsystemInterpreter
@@ -107,6 +143,7 @@ runAllEffects args =
     . interpretNowAsState
     . evalState mempty
     . evalState mempty
+    . evalState mempty
     . (evalState mempty . inMemoryUserKeyStoreInterpreter . raiseUnder)
     . inMemoryInvitationStoreInterpreter
     . evalState (mkStdGen 3)
@@ -116,6 +153,49 @@ runAllEffects args =
     . discardTinyLogs
     . enterpriseLoginSubsystemTestInterpreter args.constGuardResult
 
+runInviteScenarioObserved ::
+  InviteScenarioInput ->
+  Either LocalErrors InviteScenarioObservation
+runInviteScenarioObserved input =
+  runAllEffectsWithUserKeys [input.inviter] args . runTeamInvitationSubsystem config $ do
+    for_ input.liveInvitations $ \inv -> void $ insertInvitation inv 3_000_000
+    for_ input.pendingScimUsers $ \(indexTeam, email, uid) ->
+      deleteKey (mkEmailKey email) >> insertPendingScimUser indexTeam email uid
+    result <- catch (inviteUser inviterLuid input.invitationTeam invitationRequest >> pure Nothing) (pure . Just)
+    deletedUsers <- get @[UserId]
+    observedUser <- UserStore.getUser input.observedUid
+    observedIndex <- lookupPendingScimUsers input.invitationTeam input.inviteeEmail
+    pure
+      InviteScenarioObservation
+        { invitationResult = result,
+          deletedUserIds = deletedUsers,
+          observedUser,
+          observedPendingScimUsers = observedIndex
+        }
+  where
+    inviterLuid = toLocalUnsafe testDomain input.inviter.id
+    inviterMember = mkTeamMember input.inviter.id fullPermissions Nothing UserLegalHoldDisabled
+    invitationRequest =
+      InvitationRequest
+        { locale = Nothing,
+          role = Nothing,
+          inviteeName = Nothing,
+          inviteeEmail = input.inviteeEmail,
+          allowExisting = False
+        }
+    config =
+      TeamInvitationSubsystemConfig
+        { maxTeamSize = 50,
+          teamInvitationTimeout = 3_000_000,
+          blockedDomains = HashSet.empty
+        }
+    args =
+      RunAllEffectsArgs
+        { teams = Map.singleton input.invitationTeam [inviterMember],
+          initialUsers = input.inviter : input.otherUsers,
+          constGuardResult = Nothing
+        }
+
 data LocalErrors
   = ETeamMemberNotFound
   | ETeamNotFound
@@ -139,7 +219,250 @@ runLocalErrors = fmap toLocalErrors . runError . runError . runError
 spec :: Spec
 spec = do
   describe "InviteUser" $ do
-    prop "honors dommain config from `brig.domain_registration`" $
+    prop "rejects a manual invitation when a matching SCIM invitation is pending" $
+      \(tid :: TeamId)
+       (inviter0 :: StoredUser)
+       (scimUser0 :: StoredUser)
+       (inviterEmail :: EmailAddress)
+       (inviteeEmail :: EmailAddress)
+       (code :: InvitationCode) ->
+          inviter0.id /= scimUser0.id ==>
+            let inviter :: StoredUser
+                inviter =
+                  inviter0
+                    { email = Just inviterEmail,
+                      activated = True,
+                      status = Just Active,
+                      teamId = Just tid,
+                      managedBy = Just ManagedByWire,
+                      userType = Just UserTypeRegular
+                    }
+
+                scimUser :: StoredUser
+                scimUser =
+                  scimUser0
+                    { email = Just inviteeEmail,
+                      emailUnvalidated = Nothing,
+                      activated = False,
+                      status = Just PendingInvitation,
+                      teamId = Just tid,
+                      managedBy = Just ManagedByScim,
+                      userType = Just UserTypeRegular
+                    }
+
+                storedInvitation =
+                  MkInsertInvitation
+                    { invitationId = Id (toUUID scimUser.id),
+                      teamId = tid,
+                      role = defaultRole,
+                      createdAt = defaultTime,
+                      createdBy = Just inviter.id,
+                      inviteeEmail = inviteeEmail,
+                      inviteeName = Nothing,
+                      code = code
+                    }
+
+                outcome =
+                  runInviteScenarioObserved
+                    InviteScenarioInput
+                      { invitationTeam = tid,
+                        inviter,
+                        otherUsers = [scimUser],
+                        pendingScimUsers = [(tid, inviteeEmail, scimUser.id)],
+                        liveInvitations = [storedInvitation],
+                        inviteeEmail,
+                        observedUid = scimUser.id
+                      }
+             in counterexample (show (inviter, scimUser, storedInvitation)) $
+                  outcome
+                    === Right
+                      InviteScenarioObservation
+                        { invitationResult = Just TeamInvitationEmailTaken,
+                          deletedUserIds = [],
+                          observedUser = Just scimUser,
+                          observedPendingScimUsers = [scimUser.id]
+                        }
+
+    prop "allows a manual invitation after a matching SCIM invitation expired" $
+      \(tid :: TeamId)
+       (inviter0 :: StoredUser)
+       (scimUser0 :: StoredUser)
+       (inviterEmail :: EmailAddress)
+       (inviteeEmail :: EmailAddress) ->
+          inviter0.id /= scimUser0.id ==>
+            let inviter =
+                  inviter0
+                    { email = Just inviterEmail,
+                      activated = True,
+                      status = Just Active,
+                      teamId = Just tid,
+                      managedBy = Just ManagedByWire,
+                      userType = Just UserTypeRegular
+                    } ::
+                    StoredUser
+                scimUser =
+                  scimUser0
+                    { email = Just inviteeEmail,
+                      activated = False,
+                      status = Just PendingInvitation,
+                      teamId = Just tid,
+                      managedBy = Just ManagedByScim,
+                      userType = Just UserTypeRegular
+                    } ::
+                    StoredUser
+                outcome =
+                  runInviteScenarioObserved
+                    InviteScenarioInput
+                      { invitationTeam = tid,
+                        inviter,
+                        otherUsers = [scimUser],
+                        pendingScimUsers = [(tid, inviteeEmail, scimUser.id)],
+                        liveInvitations = [],
+                        inviteeEmail,
+                        observedUid = scimUser.id
+                      }
+             in outcome
+                  === Right
+                    InviteScenarioObservation
+                      { invitationResult = Nothing,
+                        deletedUserIds = [scimUser.id],
+                        observedUser = Nothing,
+                        observedPendingScimUsers = []
+                      }
+
+    prop "rejects a manual invitation for an active SCIM account" $
+      \(tid :: TeamId)
+       (inviter0 :: StoredUser)
+       (scimUser0 :: StoredUser)
+       (inviterEmail :: EmailAddress)
+       (inviteeEmail :: EmailAddress) ->
+          inviter0.id /= scimUser0.id ==>
+            let inviter =
+                  inviter0
+                    { email = Just inviterEmail,
+                      activated = True,
+                      status = Just Active,
+                      teamId = Just tid,
+                      managedBy = Just ManagedByWire,
+                      userType = Just UserTypeRegular
+                    } ::
+                    StoredUser
+                scimUser =
+                  scimUser0
+                    { email = Just inviteeEmail,
+                      activated = True,
+                      status = Just Active,
+                      teamId = Just tid,
+                      managedBy = Just ManagedByScim,
+                      userType = Just UserTypeRegular
+                    } ::
+                    StoredUser
+                outcome =
+                  runInviteScenarioObserved
+                    InviteScenarioInput
+                      { invitationTeam = tid,
+                        inviter,
+                        otherUsers = [scimUser],
+                        pendingScimUsers = [(tid, inviteeEmail, scimUser.id)],
+                        liveInvitations = [],
+                        inviteeEmail,
+                        observedUid = scimUser.id
+                      }
+             in outcome
+                  === Right
+                    InviteScenarioObservation
+                      { invitationResult = Just TeamInvitationEmailTaken,
+                        deletedUserIds = [],
+                        observedUser = Just scimUser,
+                        observedPendingScimUsers = [scimUser.id]
+                      }
+
+    prop "allows a manual invitation when the SCIM index entry is stale" $
+      \(tid :: TeamId)
+       (inviter :: StoredUser)
+       (staleUid :: UserId)
+       (inviterEmail :: EmailAddress)
+       (inviteeEmail :: EmailAddress) ->
+          inviter.id /= staleUid ==>
+            let preparedInviter =
+                  inviter
+                    { email = Just inviterEmail,
+                      activated = True,
+                      status = Just Active,
+                      teamId = Just tid,
+                      managedBy = Just ManagedByWire,
+                      userType = Just UserTypeRegular
+                    } ::
+                    StoredUser
+                outcome =
+                  runInviteScenarioObserved
+                    InviteScenarioInput
+                      { invitationTeam = tid,
+                        inviter = preparedInviter,
+                        otherUsers = [],
+                        pendingScimUsers = [(tid, inviteeEmail, staleUid)],
+                        liveInvitations = [],
+                        inviteeEmail,
+                        observedUid = staleUid
+                      }
+             in outcome
+                  === Right
+                    InviteScenarioObservation
+                      { invitationResult = Nothing,
+                        deletedUserIds = [],
+                        observedUser = Nothing,
+                        observedPendingScimUsers = []
+                      }
+
+    prop "allows a manual invitation in another team despite a pending SCIM invitation" $
+      \(scimTeam :: TeamId)
+       (manualTeam :: TeamId)
+       (inviter0 :: StoredUser)
+       (scimUser0 :: StoredUser)
+       (inviterEmail :: EmailAddress)
+       (inviteeEmail :: EmailAddress) ->
+          scimTeam /= manualTeam && inviter0.id /= scimUser0.id ==>
+            let inviter =
+                  inviter0
+                    { email = Just inviterEmail,
+                      activated = True,
+                      status = Just Active,
+                      teamId = Just manualTeam,
+                      managedBy = Just ManagedByWire,
+                      userType = Just UserTypeRegular
+                    } ::
+                    StoredUser
+                scimUser =
+                  scimUser0
+                    { email = Just inviteeEmail,
+                      activated = False,
+                      status = Just PendingInvitation,
+                      teamId = Just scimTeam,
+                      managedBy = Just ManagedByScim,
+                      userType = Just UserTypeRegular
+                    } ::
+                    StoredUser
+                outcome =
+                  runInviteScenarioObserved
+                    InviteScenarioInput
+                      { invitationTeam = manualTeam,
+                        inviter,
+                        otherUsers = [scimUser],
+                        pendingScimUsers = [(scimTeam, inviteeEmail, scimUser.id)],
+                        liveInvitations = [],
+                        inviteeEmail,
+                        observedUid = scimUser.id
+                      }
+             in outcome
+                  === Right
+                    InviteScenarioObservation
+                      { invitationResult = Nothing,
+                        deletedUserIds = [],
+                        observedUser = Just scimUser,
+                        observedPendingScimUsers = []
+                      }
+
+    prop "honors domain config from `brig.domain_registration`" $
       \(tid :: TeamId)
        (preDomRegUpd :: DomainRegistrationUpdate)
        (preInviter :: StoredUser)
diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal
index b5d59ad1dd3..6a8dc1f408f 100644
--- a/services/brig/brig.cabal
+++ b/services/brig/brig.cabal
@@ -182,6 +182,7 @@ library
     Brig.Schema.V90_DomainRegistrationTeamIndex
     Brig.Schema.V91_UpdateDomainRegistrationSchema_AddWebappUrl
     Brig.Schema.V92_AddUserType
+    Brig.Schema.V93_AddScimPendingUserEmail
     Brig.Team.API
     Brig.Team.Template
     Brig.Template
diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs
index 9967a35dfb7..86f9f2e9b2d 100644
--- a/services/brig/src/Brig/API/Internal.hs
+++ b/services/brig/src/Brig/API/Internal.hs
@@ -129,7 +129,7 @@ import Wire.Sem.Concurrency
 import Wire.Sem.Now (Now)
 import Wire.Sem.Random (Random)
 import Wire.SparAPIAccess (SparAPIAccess)
-import Wire.StoredUser (StoredUser (emailUnvalidated))
+import Wire.StoredUser (StoredUser (..))
 import Wire.TeamInvitationSubsystem
 import Wire.TeamSubsystem (TeamSubsystem)
 import Wire.UserGroupSubsystem
@@ -629,6 +629,7 @@ createUserNoVerifySpar uData =
 deleteUserNoAuthH ::
   ( Member (Embed HttpClientIO) r,
     Member NotificationSubsystem r,
+    Member InvitationStore r,
     Member UserStore r,
     Member TinyLog r,
     Member UserKeyStore r,
diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs
index e7328564df2..6a399bf1635 100644
--- a/services/brig/src/Brig/API/Public.hs
+++ b/services/brig/src/Brig/API/Public.hs
@@ -1508,6 +1508,7 @@ deleteSelfUser ::
     Member (Embed HttpClientIO) r,
     Member UserKeyStore r,
     Member NotificationSubsystem r,
+    Member InvitationStore r,
     Member UserStore r,
     Member EmailSubsystem r,
     Member UserSubsystem r,
@@ -1529,6 +1530,7 @@ deleteSelfUser lu body = do
 verifyDeleteUser ::
   ( Member (Embed HttpClientIO) r,
     Member NotificationSubsystem r,
+    Member InvitationStore r,
     Member UserStore r,
     Member TinyLog r,
     Member UserKeyStore r,
diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs
index f785d6513d0..bbf1d2d86d1 100644
--- a/services/brig/src/Brig/API/User.hs
+++ b/services/brig/src/Brig/API/User.hs
@@ -637,6 +637,7 @@ createUserInviteViaScim ::
     Member UserKeyStore r,
     Member UserStore r,
     Member UserSubsystem r,
+    Member InvitationStore r,
     Member (UserPendingActivationStore p) r,
     Member TinyLog r,
     Member (Input (Local ())) r
@@ -657,7 +658,9 @@ createUserInviteViaScim (NewUserScimInvitation tid uid extId loc name email _) =
     pure $ addUTCTime (realToFrac ttl) now
   lift . liftSem $ UserPendingActivationStore.add (UserPendingActivation uid expiresAt)
 
-  lift . liftSem $ UserStore.createUser account Nothing
+  lift . liftSem $ do
+    UserStore.createUser account Nothing
+    InvitationStore.insertPendingScimUser tid email uid
   newStoredUserToUser . Qualified account <$> viewFederationDomain
 
 -- | docs/reference/user/registration.md {#RefRestrictRegistration}.
@@ -1012,6 +1015,7 @@ deleteSelfUser ::
     Member (Embed HttpClientIO) r,
     Member UserKeyStore r,
     Member NotificationSubsystem r,
+    Member InvitationStore r,
     Member UserStore r,
     Member EmailSubsystem r,
     Member VerificationCodeSubsystem r,
@@ -1087,6 +1091,7 @@ deleteSelfUser luid@(tUnqualified -> uid) pwd = do
 verifyDeleteUser ::
   ( Member (Embed HttpClientIO) r,
     Member NotificationSubsystem r,
+    Member InvitationStore r,
     Member UserKeyStore r,
     Member TinyLog r,
     Member UserStore r,
@@ -1119,6 +1124,7 @@ ensureAccountDeleted ::
   ( Member (Embed HttpClientIO) r,
     Member NotificationSubsystem r,
     Member TinyLog r,
+    Member InvitationStore r,
     Member UserKeyStore r,
     Member UserStore r,
     Member Events r,
@@ -1172,6 +1178,7 @@ deleteAccount ::
     Member UserKeyStore r,
     Member TinyLog r,
     Member UserStore r,
+    Member InvitationStore r,
     Member PropertySubsystem r,
     Member UserSubsystem r,
     Member Events r,
@@ -1190,6 +1197,9 @@ deleteAccount user = do
 
     PropertySubsystem.onUserDeleted uid
     UserStore.deleteUser user
+    for_ (userEmail user) $ \email ->
+      for_ (userTeam user) $ \tid ->
+        InvitationStore.deletePendingScimUser tid email uid
 
   traverse_ (removeUserFromAllGroups uid) user.userTeam
 
diff --git a/services/brig/src/Brig/Data/User.hs b/services/brig/src/Brig/Data/User.hs
index 3a36c4c3054..fe176284211 100644
--- a/services/brig/src/Brig/Data/User.hs
+++ b/services/brig/src/Brig/Data/User.hs
@@ -22,7 +22,6 @@ module Brig.Data.User
   ( -- * Creation
     newStoredUser,
     newStoredUserViaScim,
-    invitationIdToUserId,
   )
 where
 
@@ -41,10 +40,6 @@ import Wire.API.User
 import Wire.AuthenticationSubsystem.Config
 import Wire.StoredUser
 
--- | Pending invitation users reuse the invitation UUID as the user UUID.
-invitationIdToUserId :: InvitationId -> UserId
-invitationIdToUserId = Id . toUUID
-
 -- | Preconditions:
 --
 -- 1. @newUserUUID u == Just inv || isNothing (newUserUUID u)@.
diff --git a/services/brig/src/Brig/InternalEvent/Process.hs b/services/brig/src/Brig/InternalEvent/Process.hs
index af018889184..9d148ac70ff 100644
--- a/services/brig/src/Brig/InternalEvent/Process.hs
+++ b/services/brig/src/Brig/InternalEvent/Process.hs
@@ -38,6 +38,7 @@ import Wire.API.UserEvent
 import Wire.AuthenticationSubsystem
 import Wire.ClientStore (ClientStore)
 import Wire.Events (Events)
+import Wire.InvitationStore (InvitationStore)
 import Wire.NotificationSubsystem
 import Wire.PropertySubsystem
 import Wire.Sem.Concurrency
@@ -59,6 +60,7 @@ onEvent ::
     Member (Input (Local ())) r,
     Member UserKeyStore r,
     Member UserStore r,
+    Member InvitationStore r,
     Member PropertySubsystem r,
     Member UserSubsystem r,
     Member Events r,
diff --git a/services/brig/src/Brig/Schema/Run.hs b/services/brig/src/Brig/Schema/Run.hs
index bef0e82ce37..560cf64f2e7 100644
--- a/services/brig/src/Brig/Schema/Run.hs
+++ b/services/brig/src/Brig/Schema/Run.hs
@@ -67,6 +67,7 @@ import Brig.Schema.V89_UpdateDomainRegistrationSchema qualified as V89_UpdateDom
 import Brig.Schema.V90_DomainRegistrationTeamIndex qualified as V90_DomainRegistrationTeamIndex
 import Brig.Schema.V91_UpdateDomainRegistrationSchema_AddWebappUrl qualified as V91_UpdateDomainRegistrationSchema_AddWebappUrl
 import Brig.Schema.V92_AddUserType qualified as V92_AddUserType
+import Brig.Schema.V93_AddScimPendingUserEmail qualified as V93_AddScimPendingUserEmail
 import Cassandra.MigrateSchema (migrateSchema)
 import Cassandra.Schema
 import Control.Exception (finally)
@@ -140,7 +141,8 @@ migrations =
     V89_UpdateDomainRegistrationSchema.migration,
     V90_DomainRegistrationTeamIndex.migration,
     V91_UpdateDomainRegistrationSchema_AddWebappUrl.migration,
-    V92_AddUserType.migration
+    V92_AddUserType.migration,
+    V93_AddScimPendingUserEmail.migration
     -- FUTUREWORK: undo V41 (searchable flag); we stopped using it in
     -- https://github.com/wireapp/wire-server/pull/964
   ]
diff --git a/services/brig/src/Brig/Schema/V93_AddScimPendingUserEmail.hs b/services/brig/src/Brig/Schema/V93_AddScimPendingUserEmail.hs
new file mode 100644
index 00000000000..bca1d4ea904
--- /dev/null
+++ b/services/brig/src/Brig/Schema/V93_AddScimPendingUserEmail.hs
@@ -0,0 +1,41 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Brig.Schema.V93_AddScimPendingUserEmail
+  ( migration,
+  )
+where
+
+import Cassandra.Schema
+import Imports
+import Text.RawString.QQ
+
+migration :: Migration
+migration =
+  Migration 93 "Add lookup table for pending SCIM users by team and email" $
+    schema'
+      [r|
+        CREATE TABLE team_scim_pending_user_email
+            ( team uuid
+            , email text
+            , user uuid
+            , primary key ((team, email), user)
+            )
+    |]
diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs
index 323b260960b..48430fbebab 100644
--- a/services/brig/src/Brig/Team/API.hs
+++ b/services/brig/src/Brig/Team/API.hs
@@ -31,7 +31,6 @@ import Brig.API.User (createUserInviteViaScim)
 import Brig.API.User qualified as API
 import Brig.API.Util (logEmail, logInvitationCode)
 import Brig.App as App
-import Brig.Data.User (invitationIdToUserId)
 import Brig.Template
 import Control.Lens (view, (^.))
 import Control.Monad.Trans.Except
@@ -147,6 +146,7 @@ createInvitationViaScim ::
   ( Member BlockListStore r,
     Member UserKeyStore r,
     Member UserStore r,
+    Member InvitationStore r,
     Member (UserPendingActivationStore p) r,
     Member TinyLog r,
     Member TeamInvitationSubsystem r,

From d8fb5afcda7a7f791aa1e5d6d724a9dde47adb03 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Thu, 6 Aug 2026 15:27:47 +0200
Subject: [PATCH 071/113] WPB-23631: move ScimExternalIdStore law tests from
 library to test-suite (#5423)

---
 changelog.d/5-internal/WPB-23631-6            |  2 +-
 .../unit/Wire/ScimExternalIdStore/MemSpec.hs} | 24 ++++++++++++--
 libs/wire-subsystems/wire-subsystems.cabal    |  2 +-
 services/spar/spar.cabal                      |  1 -
 .../Test/Spar/Sem/ScimExternalIdStoreSpec.hs  | 32 -------------------
 5 files changed, 24 insertions(+), 37 deletions(-)
 rename libs/wire-subsystems/{src/Wire/ScimExternalIdStore/Spec.hs => test/unit/Wire/ScimExternalIdStore/MemSpec.hs} (89%)
 delete mode 100644 services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs

diff --git a/changelog.d/5-internal/WPB-23631-6 b/changelog.d/5-internal/WPB-23631-6
index 527fc5d0fab..1d5fd9d1ddf 100644
--- a/changelog.d/5-internal/WPB-23631-6
+++ b/changelog.d/5-internal/WPB-23631-6
@@ -1 +1 @@
-Move `Spar.Sem.ScimExternalIdStore` in `Wire.ScimExternalIdStore`.
+Move `Spar.Sem.ScimExternalIdStore` in `Wire.ScimExternalIdStore`. (#5392, #5423)
diff --git a/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Spec.hs b/libs/wire-subsystems/test/unit/Wire/ScimExternalIdStore/MemSpec.hs
similarity index 89%
rename from libs/wire-subsystems/src/Wire/ScimExternalIdStore/Spec.hs
rename to libs/wire-subsystems/test/unit/Wire/ScimExternalIdStore/MemSpec.hs
index dbdb25ff4aa..d08c101798f 100644
--- a/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Spec.hs
+++ b/libs/wire-subsystems/test/unit/Wire/ScimExternalIdStore/MemSpec.hs
@@ -1,4 +1,5 @@
 {-# LANGUAGE QuantifiedConstraints #-}
+{-# OPTIONS_GHC -Wno-orphans #-}
 {-# OPTIONS_GHC -fplugin=Polysemy.Plugin #-}
 
 -- This file is part of the Wire Server implementation.
@@ -18,7 +19,7 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Wire.ScimExternalIdStore.Spec (propsForInterpreter) where
+module Wire.ScimExternalIdStore.MemSpec (spec) where
 
 import Data.Id
 import Imports
@@ -29,6 +30,25 @@ import Test.Hspec.QuickCheck
 import Test.QuickCheck
 import Wire.API.User.Scim (ScimUserCreationStatus, ValidScimId)
 import Wire.ScimExternalIdStore qualified as E
+import Wire.ScimExternalIdStore.Mem (scimExternalIdStoreToMem)
+
+spec :: Spec
+spec =
+  modifyMaxSuccess (const 1000) $
+    propsForInterpreter "scimExternalIdStoreToMem" snd $
+      pure . run . scimExternalIdStoreToMem
+
+-- 'CoArbitrary' is required by 'Polysemy.Check' for the argument types of the
+-- effect's operations.  These instances are orphans here (neither the type nor
+-- the class is defined in this package); they mirror
+-- @services/spar/test/Arbitrary.hs@.  Blank instances resolve via QuickCheck's
+-- generic default; both types derive 'Generic' ('UserId' in types-common,
+-- 'ScimUserCreationStatus' in @Wire.API.User.Scim@).  Being in scope here, they
+-- also discharge the matching 'PropConstraints' superclasses, so the instance
+-- head below omits them (stating them would trip @-Wredundant-constraints@).
+instance CoArbitrary UserId
+
+instance CoArbitrary ScimUserCreationStatus
 
 propsForInterpreter ::
   (PropConstraints r f) =>
@@ -57,7 +77,7 @@ class
   PropConstraints r f
 
 instance
-  (CoArbitrary UserId, CoArbitrary ScimUserCreationStatus, Functor f, Member E.ScimExternalIdStore r, forall z. (Show z) => Show (f z), forall z. (Eq z) => Eq (f z)) =>
+  (Functor f, Member E.ScimExternalIdStore r, forall z. (Show z) => Show (f z), forall z. (Eq z) => Eq (f z)) =>
   PropConstraints r f
 
 -- | Adapt the fully-polymorphic interpreter to the rank-2 position 'prepropLaw'
diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal
index ad2c91d6b9f..93f43dfd5d7 100644
--- a/libs/wire-subsystems/wire-subsystems.cabal
+++ b/libs/wire-subsystems/wire-subsystems.cabal
@@ -440,7 +440,6 @@ library
     Wire.ScimExternalIdStore
     Wire.ScimExternalIdStore.Cassandra
     Wire.ScimExternalIdStore.Mem
-    Wire.ScimExternalIdStore.Spec
     Wire.ScimSubsystem
     Wire.ScimSubsystem.Error
     Wire.ScimSubsystem.Interpreter
@@ -686,6 +685,7 @@ test-suite wire-subsystems-tests
     Wire.PropertySubsystem.InterpreterSpec
     Wire.RateLimited.InterpreterSpec
     Wire.SAMLEmailSubsystem.InterpreterSpec
+    Wire.ScimExternalIdStore.MemSpec
     Wire.ScimSubsystem.InterpreterSpec
     Wire.StoredConversationSpec
     Wire.TeamCollaboratorsSubsystem.InterpreterSpec
diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal
index 9d6b57ab137..8bd4d03aac8 100644
--- a/services/spar/spar.cabal
+++ b/services/spar/spar.cabal
@@ -539,7 +539,6 @@ test-suite spec
     Test.Spar.Sem.IdPRawMetadataStoreSpec
     Test.Spar.Sem.NowSpec
     Test.Spar.Sem.SamlProtocolSettingsSpec
-    Test.Spar.Sem.ScimExternalIdStoreSpec
     Test.Spar.TypesSpec
 
   hs-source-dirs:     test
diff --git a/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs b/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs
deleted file mode 100644
index e00872f2214..00000000000
--- a/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs
+++ /dev/null
@@ -1,32 +0,0 @@
-{-# OPTIONS_GHC -fplugin=Polysemy.Plugin #-}
-
--- This file is part of the Wire Server implementation.
---
--- Copyright (C) 2022 Wire Swiss GmbH 
---
--- This program is free software: you can redistribute it and/or modify it under
--- the terms of the GNU Affero General Public License as published by the Free
--- Software Foundation, either version 3 of the License, or (at your option) any
--- later version.
---
--- This program is distributed in the hope that it will be useful, but WITHOUT
--- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
--- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
--- details.
---
--- You should have received a copy of the GNU Affero General Public License along
--- with this program. If not, see .
-
-module Test.Spar.Sem.ScimExternalIdStoreSpec where
-
-import Arbitrary ()
-import Imports
-import Polysemy
-import Test.Hspec
-import Test.Hspec.QuickCheck
-import Wire.ScimExternalIdStore.Mem
-import Wire.ScimExternalIdStore.Spec
-
-spec :: Spec
-spec = modifyMaxSuccess (const 1000) $ do
-  propsForInterpreter "scimExternalIdStoreToMem" snd $ pure . run . scimExternalIdStoreToMem

From f9d161b04817ab326bd6d938e90f3d64c98da98b Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Thu, 6 Aug 2026 15:28:24 +0200
Subject: [PATCH 072/113] WPB-26626: emit conversation.delete-meeting for
 meeting conversations (#5421)

---
 changelog.d/1-api-changes/WPB-26626-events.md |  1 +
 integration/test/Notifications.hs             |  4 +
 integration/test/Test/Meetings.hs             |  7 +-
 .../src/Wire/API/Event/Conversation.hs        |  8 ++
 .../golden/Test/Wire/API/Golden/Generated.hs  |  5 +-
 .../Wire/API/Golden/Generated/Event_user.hs   | 20 +++++
 .../test/golden/testObject_Event_user_17.json | 76 +++++++++++++++++++
 .../test/golden/testObject_Event_user_18.json | 17 +++++
 .../test/golden/testObject_Event_user_19.json | 16 ++++
 .../test/unit/Test/Wire/API/Conversation.hs   |  1 +
 .../src/Wire/ConversationSubsystem/Notify.hs  |  9 ++-
 11 files changed, 161 insertions(+), 3 deletions(-)
 create mode 100644 changelog.d/1-api-changes/WPB-26626-events.md
 create mode 100644 libs/wire-api/test/golden/testObject_Event_user_17.json
 create mode 100644 libs/wire-api/test/golden/testObject_Event_user_18.json
 create mode 100644 libs/wire-api/test/golden/testObject_Event_user_19.json

diff --git a/changelog.d/1-api-changes/WPB-26626-events.md b/changelog.d/1-api-changes/WPB-26626-events.md
new file mode 100644
index 00000000000..3763b103d62
--- /dev/null
+++ b/changelog.d/1-api-changes/WPB-26626-events.md
@@ -0,0 +1 @@
+Introduced meeting-specific conversation lifecycle events: `conversation.create-meeting` and `conversation.delete-meeting`. When a conversation of type meeting (`group_conv_type: "meeting"`) is created or deleted, clients receive these instead of `conversation.create` / `conversation.delete`. The payloads are identical to their non-meeting counterparts (`conversation.delete-meeting`, like `conversation.delete`, carries no `data`); only the event `type` differs, so clients can handle meetings distinctly. (WPB-26626)
diff --git a/integration/test/Notifications.hs b/integration/test/Notifications.hs
index d842a7991a6..20795a4d51b 100644
--- a/integration/test/Notifications.hs
+++ b/integration/test/Notifications.hs
@@ -199,6 +199,10 @@ isConvCreateMeetingNotif :: (HasCallStack, MakesValue a) => a -> App Bool
 isConvCreateMeetingNotif n =
   fieldEquals n "payload.0.type" "conversation.create-meeting"
 
+isConvDeleteMeetingNotif :: (HasCallStack, MakesValue a) => a -> App Bool
+isConvDeleteMeetingNotif n =
+  fieldEquals n "payload.0.type" "conversation.delete-meeting"
+
 isMeetingCreateNotif :: (HasCallStack, MakesValue a) => a -> App Bool
 isMeetingCreateNotif n =
   fieldEquals n "payload.0.type" "meeting.create"
diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs
index 61d2484b6c1..007f4983779 100644
--- a/integration/test/Test/Meetings.hs
+++ b/integration/test/Test/Meetings.hs
@@ -10,7 +10,7 @@ import qualified Data.Text.Encoding as Text
 import Data.Time.Clock
 import qualified Data.Time.Format as Time
 import MLS.Util
-import Notifications (isConvCreateMeetingNotif, isMeetingCreateNotif, isMeetingDeleteNotif, isMeetingMemberAddNotif, isMeetingUpdateNotif, isMemberJoinNotif, isWelcomeNotif)
+import Notifications (isConvCreateMeetingNotif, isConvDeleteMeetingNotif, isMeetingCreateNotif, isMeetingDeleteNotif, isMeetingMemberAddNotif, isMeetingUpdateNotif, isMemberJoinNotif, isWelcomeNotif)
 import SetupHelpers
 import System.Timeout (timeout)
 import Testlib.Prelude
@@ -505,8 +505,13 @@ testMeetingDelete = do
   (meetingId, domain) <- getMeetingIdAndDomain meeting
   withWebSocket owner $ \ws -> do
     deleteMeeting owner domain meetingId >>= assertStatus 200
+    void $ awaitMatch isConvDeleteMeetingNotif ws
     deleteNotif <- awaitMatch isMeetingDeleteNotif ws
     assertMeetingNotif deleteNotif (object ["id" .= meetingId, "domain" .= domain])
+    -- A meeting conversation must emit `conversation.delete-meeting`, never the
+    -- plain `conversation.delete` (EdConvDelete). After the two events above the
+    -- socket should be quiet; any leaked delete would surface here.
+    assertNoEvent 1 ws
   getMeeting owner domain meetingId >>= assertStatus 404
 
 testMeetingDeleteNotFound :: (HasCallStack) => App ()
diff --git a/libs/wire-api/src/Wire/API/Event/Conversation.hs b/libs/wire-api/src/Wire/API/Event/Conversation.hs
index f6b28a24d43..f39a745fcf8 100644
--- a/libs/wire-api/src/Wire/API/Event/Conversation.hs
+++ b/libs/wire-api/src/Wire/API/Event/Conversation.hs
@@ -52,6 +52,7 @@ module Wire.API.Event.Conversation
     _EdConvReceiptModeUpdate,
     _EdConvRename,
     _EdConvDelete,
+    _EdConvDeleteMeeting,
     _EdConvAccessUpdate,
     _EdConvMessageTimerUpdate,
     _EdConvCodeUpdate,
@@ -195,6 +196,7 @@ data EventType
   | ConvCreateMeeting
   | ConvConnect
   | ConvDelete
+  | ConvDeleteMeeting
   | ConvReset
   | ConvReceiptModeUpdate
   | OtrMessageAdd
@@ -225,6 +227,7 @@ instance ToSchema EventType where
           element "conversation.create" ConvCreate,
           element "conversation.create-meeting" ConvCreateMeeting,
           element "conversation.delete" ConvDelete,
+          element "conversation.delete-meeting" ConvDeleteMeeting,
           element "conversation.mls-reset" ConvReset,
           element "conversation.connect-request" ConvConnect,
           element "conversation.typing" Typing,
@@ -244,6 +247,7 @@ data EventData
   | EdConvReceiptModeUpdate ConversationReceiptModeUpdate
   | EdConvRename ConversationRename
   | EdConvDelete
+  | EdConvDeleteMeeting
   | EdConvReset ConversationReset
   | EdConvAccessUpdate ConversationAccessData
   | EdConvMessageTimerUpdate ConversationMessageTimerUpdate
@@ -281,6 +285,7 @@ genEventData = \case
   MLSMessageAdd -> EdMLSMessage <$> arbitrary
   MLSWelcome -> EdMLSWelcome <$> arbitrary
   ConvDelete -> pure EdConvDelete
+  ConvDeleteMeeting -> pure EdConvDeleteMeeting
   ConvReset -> EdConvReset <$> arbitrary
   ProtocolUpdate -> EdProtocolUpdate <$> arbitrary
   AddPermissionUpdate -> EdAddPermissionUpdate <$> arbitrary
@@ -305,6 +310,7 @@ eventDataType (EdOtrMessage _) = OtrMessageAdd
 eventDataType (EdMLSMessage _) = MLSMessageAdd
 eventDataType (EdMLSWelcome _) = MLSWelcome
 eventDataType EdConvDelete = ConvDelete
+eventDataType EdConvDeleteMeeting = ConvDeleteMeeting
 eventDataType (EdConvReset _) = ConvReset
 eventDataType (EdProtocolUpdate _) = ProtocolUpdate
 eventDataType (EdAddPermissionUpdate _) = AddPermissionUpdate
@@ -327,6 +333,7 @@ isCellsConversationEvent eventType =
     ConvCreate -> True
     ConvCreateMeeting -> True
     ConvDelete -> True
+    ConvDeleteMeeting -> True
     ConvReset -> False
     ConvCodeDelete -> False
     ConvAccessUpdate -> False
@@ -646,6 +653,7 @@ taggedEventDataSchema =
       Typing -> tag _EdTyping (unnamed schema)
       ConvCodeDelete -> tag _EdConvCodeDelete null_
       ConvDelete -> tag _EdConvDelete null_
+      ConvDeleteMeeting -> tag _EdConvDeleteMeeting null_
       ConvReset -> tag _EdConvReset (unnamed schema)
       ProtocolUpdate -> tag _EdProtocolUpdate (unnamed (unProtocolUpdate <$> P.ProtocolUpdate .= schema))
       AddPermissionUpdate -> tag _EdAddPermissionUpdate (unnamed schema)
diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs
index 02dc1d21e43..3489a1b257a 100644
--- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs
+++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs
@@ -679,7 +679,10 @@ tests =
             (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_13, "testObject_Event_user_13.json"),
             (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_14, "testObject_Event_user_14.json"),
             (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_15, "testObject_Event_user_15.json"),
-            (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_16, "testObject_Event_user_16.json")
+            (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_16, "testObject_Event_user_16.json"),
+            (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_17, "testObject_Event_user_17.json"),
+            (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_18, "testObject_Event_user_18.json"),
+            (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_19, "testObject_Event_user_19.json")
           ],
       testGroup "Golden: EventType_user" $
         testObjects
diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_user.hs
index 1341f2f6cc7..a6d987ce5e9 100644
--- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_user.hs
+++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_user.hs
@@ -412,3 +412,23 @@ testObject_Event_user_17 =
             }
         )
     )
+
+testObject_Event_user_18 :: Event
+testObject_Event_user_18 =
+  Event
+    (Qualified (Id (fromJust (UUID.fromString "00005d81-0000-0d71-0000-1d8f00007d32"))) (Domain "faraway.example.com"))
+    Nothing
+    (EventFromUser (Qualified (Id (fromJust (UUID.fromString "00003b8b-0000-3395-0000-076a00007830"))) (Domain "faraway.example.com")))
+    (read "1864-05-22 09:51:07.104 UTC")
+    (Just (Id (fromJust (UUID.fromString "90eda181-bb05-4525-a5cc-d0038deda9b7"))))
+    EdConvDeleteMeeting
+
+testObject_Event_user_19 :: Event
+testObject_Event_user_19 =
+  Event
+    (Qualified (Id (fromJust (UUID.fromString "00005d81-0000-0d71-0000-1d8f00007d32"))) (Domain "faraway.example.com"))
+    Nothing
+    (EventFromUser (Qualified (Id (fromJust (UUID.fromString "00003b8b-0000-3395-0000-076a00007830"))) (Domain "faraway.example.com")))
+    (read "1864-05-22 09:51:07.104 UTC")
+    Nothing
+    EdConvDeleteMeeting
diff --git a/libs/wire-api/test/golden/testObject_Event_user_17.json b/libs/wire-api/test/golden/testObject_Event_user_17.json
new file mode 100644
index 00000000000..8ae5d494723
--- /dev/null
+++ b/libs/wire-api/test/golden/testObject_Event_user_17.json
@@ -0,0 +1,76 @@
+{
+    "conversation": "00007d9a-0000-4f23-0000-2b8a000057c1",
+    "data": {
+        "access": [
+            "private",
+            "invite"
+        ],
+        "access_role": "activated",
+        "access_role_v2": [
+            "team_member",
+            "non_team_member"
+        ],
+        "add_permission": null,
+        "cells_state": "disabled",
+        "creator": "00000000-0000-0000-0000-000400000001",
+        "group_conv_type": "meeting",
+        "history": null,
+        "id": "00000000-0000-0000-0000-000300000001",
+        "last_event": "0.0",
+        "last_event_time": "1970-01-01T00:00:00.000Z",
+        "members": {
+            "others": [
+                {
+                    "conversation_role": "wire_member",
+                    "id": "00000000-0000-0006-0000-000100000001",
+                    "qualified_id": {
+                        "domain": "golden.example.com",
+                        "id": "00000000-0000-0006-0000-000100000001"
+                    },
+                    "status": 0
+                }
+            ],
+            "self": {
+                "conversation_role": "wire_admin",
+                "hidden": false,
+                "hidden_ref": null,
+                "id": "00000002-0000-0000-0000-000000000001",
+                "otr_archived": false,
+                "otr_archived_ref": null,
+                "otr_muted_ref": null,
+                "otr_muted_status": null,
+                "qualified_id": {
+                    "domain": "golden.example.com",
+                    "id": "00000002-0000-0000-0000-000000000001"
+                },
+                "service": null,
+                "status": 0,
+                "status_ref": "0.0",
+                "status_time": "1970-01-01T00:00:00.000Z"
+            }
+        },
+        "message_timer": null,
+        "name": "Meeting Room",
+        "parent": null,
+        "protocol": "proteus",
+        "qualified_id": {
+            "domain": "golden.example.com",
+            "id": "00000000-0000-0000-0000-000300000001"
+        },
+        "receipt_mode": null,
+        "team": "00000000-0000-0005-0000-000100000001",
+        "type": 0
+    },
+    "from": "00005c6b-0000-6a17-0000-3e5b00006e2f",
+    "qualified_conversation": {
+        "domain": "faraway.example.com",
+        "id": "00007d9a-0000-4f23-0000-2b8a000057c1"
+    },
+    "qualified_from": {
+        "domain": "faraway.example.com",
+        "id": "00005c6b-0000-6a17-0000-3e5b00006e2f"
+    },
+    "time": "1864-05-20T12:14:33.001Z",
+    "type": "conversation.create-meeting",
+    "via": "user"
+}
diff --git a/libs/wire-api/test/golden/testObject_Event_user_18.json b/libs/wire-api/test/golden/testObject_Event_user_18.json
new file mode 100644
index 00000000000..0698e464233
--- /dev/null
+++ b/libs/wire-api/test/golden/testObject_Event_user_18.json
@@ -0,0 +1,17 @@
+{
+    "conversation": "00005d81-0000-0d71-0000-1d8f00007d32",
+    "data": null,
+    "from": "00003b8b-0000-3395-0000-076a00007830",
+    "qualified_conversation": {
+        "domain": "faraway.example.com",
+        "id": "00005d81-0000-0d71-0000-1d8f00007d32"
+    },
+    "qualified_from": {
+        "domain": "faraway.example.com",
+        "id": "00003b8b-0000-3395-0000-076a00007830"
+    },
+    "team": "90eda181-bb05-4525-a5cc-d0038deda9b7",
+    "time": "1864-05-22T09:51:07.104Z",
+    "type": "conversation.delete-meeting",
+    "via": "user"
+}
diff --git a/libs/wire-api/test/golden/testObject_Event_user_19.json b/libs/wire-api/test/golden/testObject_Event_user_19.json
new file mode 100644
index 00000000000..e9d22fcbd6e
--- /dev/null
+++ b/libs/wire-api/test/golden/testObject_Event_user_19.json
@@ -0,0 +1,16 @@
+{
+    "conversation": "00005d81-0000-0d71-0000-1d8f00007d32",
+    "data": null,
+    "from": "00003b8b-0000-3395-0000-076a00007830",
+    "qualified_conversation": {
+        "domain": "faraway.example.com",
+        "id": "00005d81-0000-0d71-0000-1d8f00007d32"
+    },
+    "qualified_from": {
+        "domain": "faraway.example.com",
+        "id": "00003b8b-0000-3395-0000-076a00007830"
+    },
+    "time": "1864-05-22T09:51:07.104Z",
+    "type": "conversation.delete-meeting",
+    "via": "user"
+}
diff --git a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs
index 026a393bbd3..7362006272b 100644
--- a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs
+++ b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs
@@ -72,6 +72,7 @@ testIsCellsConversationEvent =
         ConvCreate -> isCellsConversationEvent e === True
         ConvCreateMeeting -> isCellsConversationEvent e === True
         ConvDelete -> isCellsConversationEvent e === True
+        ConvDeleteMeeting -> isCellsConversationEvent e === True
         ConvReset -> isCellsConversationEvent e === False
         ConvMessageTimerUpdate -> isCellsConversationEvent e === False
         ConvHistoryUpdate -> isCellsConversationEvent e === False
diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Notify.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Notify.hs
index 98130ac5571..e40ea62e46f 100644
--- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Notify.hs
+++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Notify.hs
@@ -70,7 +70,14 @@ notifyConversationActionImpl tag eventFrom notifyOrigDomain con lconv targetsLoc
   let lcnv = fmap (.id_) lconv
       conv = tUnqualified lconv
       tid = conv.metadata.cnvmTeam
-      e = conversationActionToEvent tag now eventFrom (tUntagged lcnv) extraData Nothing tid action
+      -- Meeting conversations emit `conversation.delete-meeting` instead of
+      -- `conversation.delete`, mirroring `conversation.create-meeting` (#5302).
+      eBase = conversationActionToEvent tag now eventFrom (tUntagged lcnv) extraData Nothing tid action
+      e
+        | eBase.evtData == EdConvDelete
+            && conv.metadata.cnvmGroupConvType == Just MeetingConversation =
+            eBase {evtData = EdConvDeleteMeeting}
+        | otherwise = eBase
       quid = eventFromUserId eventFrom
       mkUpdate uids =
         ConversationUpdate

From 27bb12785aea473908647b43cb53532b84b83692 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Fri, 7 Aug 2026 15:41:46 +0200
Subject: [PATCH 073/113] fix: remove broken addTeamMemberInternal & port
 one2one tests to Testlib (#5420)

---
 integration/integration.cabal                 |   1 +
 integration/test/Test/One2OneTeamConv.hs      |  83 ++++++++++++++
 services/galley/test/integration/API/Teams.hs | 102 +++++++-----------
 .../test/integration/API/Teams/LegalHold.hs   |  13 ++-
 .../API/Teams/LegalHold/DisabledByDefault.hs  |  17 ++-
 services/galley/test/integration/API/Util.hs  |  55 +++-------
 6 files changed, 152 insertions(+), 119 deletions(-)
 create mode 100644 integration/test/Test/One2OneTeamConv.hs

diff --git a/integration/integration.cabal b/integration/integration.cabal
index c36b9c4e730..d124256a118 100644
--- a/integration/integration.cabal
+++ b/integration/integration.cabal
@@ -197,6 +197,7 @@ library
     Test.NginxZAuthModule
     Test.Notifications
     Test.OAuth
+    Test.One2OneTeamConv
     Test.PasswordReset
     Test.Presence
     Test.Property
diff --git a/integration/test/Test/One2OneTeamConv.hs b/integration/test/Test/One2OneTeamConv.hs
new file mode 100644
index 00000000000..8e03a12e19c
--- /dev/null
+++ b/integration/test/Test/One2OneTeamConv.hs
@@ -0,0 +1,83 @@
+{-# OPTIONS_GHC -Wno-ambiguous-fields #-}
+
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Test.One2OneTeamConv where
+
+import API.Galley
+import Notifications (isTeamMemberJoinNotif)
+import SetupHelpers
+import Testlib.Prelude
+
+-- | Team member roles that the one2one team conversation test is parametrized
+-- over (the standard non-owner roles). Each constructor is enumerated into a
+-- separate test case by the test discovery machinery, mirroring the way the
+-- original galley test invoked @testCreateOne2OneWithMembers@ once per role.
+data One2OneRole = One2OneMember | One2OnePartner
+  deriving stock (Generic, Eq, Show)
+
+-- | Map a parametrized role to the team-role string accepted by
+-- 'createTeamMember' (see @Wire.API.Team.Role@: @member@, @partner@). The
+-- original galley test covered @RoleMember@ and @RoleExternalPartner@.
+one2OneRoleName :: One2OneRole -> String
+one2OneRoleName = \case
+  One2OneMember -> "member"
+  One2OnePartner -> "partner"
+
+-- | An owner adds a second team member with the given role and creates a
+-- binding one2one team conversation with them. Ported from
+-- @services/galley/test/integration/API/Teams.hs@
+-- (@testCreateOne2OneWithMembers@).
+testCreateOne2OneWithMembers :: (HasCallStack) => One2OneRole -> App ()
+testCreateOne2OneWithMembers memberRole = do
+  (owner, tid, []) <- createTeam OwnDomain 1
+  teamMember <-
+    withWebSockets [owner] $ \[wsOwner] -> do
+      m <- createTeamMember owner def {role = one2OneRoleName memberRole}
+      -- The owner is notified of the new member joining the team. This mirrors
+      -- @checkTeamMemberJoin@ in the galley test suite.
+      memberJoin <- awaitMatch isTeamMemberJoinNotif wsOwner
+      memberJoin %. "payload.0.team" `shouldMatch` tid
+      memberJoin %. "payload.0.data.user" `shouldMatch` objId m
+      -- The original test additionally asserts a @team.update@ event via the
+      -- galley SQS team-event queue (@assertTeamUpdate tid 2 [owner]@). That
+      -- queue is galley-test-specific and has no websocket / Testlib
+      -- equivalent (galley only pushes @team.member-join@ over websockets on a
+      -- member join), so we verify the updated team membership directly.
+      bindResponse (getTeamMembers owner tid) $ \resp -> do
+        resp.status `shouldMatchInt` 200
+        members <- resp.json %. "members" >>= asList
+        length members `shouldMatchInt` 2
+      pure m
+  -- Creating the one2one team conversation is eventually consistent: retry
+  -- until it returns 201.
+  eventually $ postOne2OneConversation owner teamMember tid "chit-chat" >>= assertStatus 201
+  -- Recreating the one2one is a no-op and returns 200.
+  bindResponse (postOne2OneConversation owner teamMember tid "chit-chat") $ \resp ->
+    resp.status `shouldMatchInt` 200
+
+-- | Two owners each create their own binding team. Attempting to create a
+-- one2one team conversation with a member of a different (binding) team fails
+-- with @non-binding-team-members@. Ported from
+-- @testCreateOne2OneFailForNonTeamMembers@.
+testCreateOne2OneFailForNonTeamMembers :: (HasCallStack) => App ()
+testCreateOne2OneFailForNonTeamMembers = do
+  (owner1, tid1, []) <- createTeam OwnDomain 1
+  (owner2, _tid2, []) <- createTeam OwnDomain 1
+  postOne2OneConversation owner1 owner2 tid1 "chit-chat"
+    >>= assertLabel 403 "non-binding-team-members"
diff --git a/services/galley/test/integration/API/Teams.hs b/services/galley/test/integration/API/Teams.hs
index 11e7f983bab..b95aa7a12ed 100644
--- a/services/galley/test/integration/API/Teams.hs
+++ b/services/galley/test/integration/API/Teams.hs
@@ -106,9 +106,6 @@ tests s =
         [test s "the list should be truncated" testUncheckedListTeamMembers],
       test s "enable/disable SSO" testEnableSSOPerTeam,
       test s "enable/disable Custom Search Visibility" testEnableTeamSearchVisibilityPerTeam,
-      test s "create 1-1 conversation between non-team members (fail)" testCreateOne2OneFailForNonTeamMembers,
-      test s "create 1-1 conversation between binding team members" (testCreateOne2OneWithMembers RoleMember),
-      test s "create 1-1 conversation between binding team members as partner" (testCreateOne2OneWithMembers RoleExternalPartner),
       test s "poll team-level event queue" testTeamQueue,
       test s "add new team member internal" testAddTeamMemberInternal,
       test s "remove aka delete team member (binding, owner has passwd)" (testRemoveBindingTeamMember True),
@@ -379,45 +376,6 @@ testEnableTeamSearchVisibilityPerTeam = do
   Util.putTeamSearchVisibilityAvailableInternal tid FeatureStatusDisabled
   getSearchVisibilityCheck SearchVisibilityStandard
 
-testCreateOne2OneFailForNonTeamMembers :: TestM ()
-testCreateOne2OneFailForNonTeamMembers = do
-  owner <- Util.randomUser
-  let p1 = Util.symmPermissions [CreateConversation, AddRemoveConvMember]
-  let p2 = Util.symmPermissions [CreateConversation, AddRemoveConvMember, AddTeamMember]
-  mem1 <- newTeamMember' p1 <$> Util.randomUser
-  mem2 <- newTeamMember' p2 <$> Util.randomUser
-  Util.connectUsers owner ((mem1 ^. userId) :| [mem2 ^. userId])
-  -- Both have a binding team but not the same team
-  owner1 <- Util.randomUser
-  tid1 <- Util.createBindingTeamInternal "foo" owner1
-  assertTeamActivate "create team" tid1
-  owner2 <- Util.randomUser
-  tid2 <- Util.createBindingTeamInternal "foo" owner2
-  assertTeamActivate "create another team" tid2
-  Util.createOne2OneTeamConv owner1 owner2 Nothing tid1 !!! do
-    const 403 === statusCode
-    const "non-binding-team-members" === (Error.label . responseJsonUnsafeWithMsg "error label")
-
-testCreateOne2OneWithMembers ::
-  (HasCallStack) =>
-  -- | Role of the user who creates the conversation
-  Role ->
-  TestM ()
-testCreateOne2OneWithMembers (rolePermissions -> perms) = do
-  c <- view tsCannon
-  (owner, tid) <- Util.createBindingTeam
-  mem1 <- newTeamMember' perms <$> Util.randomUser
-  WS.bracketR c (mem1 ^. userId) $ \wsMem1 -> do
-    Util.addTeamMemberInternal tid (mem1 ^. userId) (mem1 ^. permissions) (mem1 ^. invitation)
-    checkTeamMemberJoin tid (mem1 ^. userId) wsMem1
-    assertTeamUpdate "team member join" tid 2 [owner]
-  void $ retryWhileN 10 repeatIf (Util.createOne2OneTeamConv owner (mem1 ^. userId) Nothing tid)
-  -- Recreating a One2One is a no-op, returns a 200
-  Util.createOne2OneTeamConv owner (mem1 ^. userId) Nothing tid !!! const 200 === statusCode
-  where
-    repeatIf :: ResponseLBS -> Bool
-    repeatIf r = statusCode r /= 201
-
 -- | At the time of writing this test, the only event sent to this queue is 'MemberJoin'.
 testTeamQueue :: TestM ()
 testTeamQueue = do
@@ -484,7 +442,18 @@ testAddTeamMemberInternal = do
   let p1 = Util.symmPermissions [GetBilling] -- permissions are irrelevant on internal endpoint
   mem1 <- newTeamMember' p1 <$> Util.randomUser
   WS.bracketRN c [owner, mem1 ^. userId] $ \[wsOwner, wsMem1] -> do
-    Util.addTeamMemberInternal tid (mem1 ^. userId) (mem1 ^. permissions) (mem1 ^. invitation)
+    -- This test directly exercises the internal @POST /i/teams/:tid/members@
+    -- endpoint (formerly @Util.addTeamMemberInternal@). It is intentionally
+    -- inlined rather than exposed as a reusable helper because that endpoint
+    -- bypasses the invitation flow and must not be reused by other tests
+    -- (https://wearezeta.atlassian.net/browse/SQSERVICES-471).
+    g <- viewGalley
+    post
+      ( g
+          . paths ["i", "teams", toByteString' tid, "members"]
+          . json (Member.mkNewTeamMember (mem1 ^. userId) (mem1 ^. permissions) (mem1 ^. invitation))
+      )
+      !!! const 200 === statusCode
     liftIO . void $ mapConcurrently (checkJoinEvent tid (mem1 ^. userId)) [wsOwner, wsMem1]
     assertTeamUpdate "team member join" tid 2 [owner]
   void $ Util.getTeamMemberInternal tid (mem1 ^. userId)
@@ -736,29 +705,26 @@ testAddTeamMemberToConv :: TestM ()
 testAddTeamMemberToConv = do
   personalUser <- Util.randomUser
   (ownerT1, qOwnerT1) <- Util.randomUserTuple
+  (ownerT2, qOwnerT2) <- Util.randomUserTuple
+  tidT1 <- createBindingTeamInternal "foo" ownerT1
   let p = Util.symmPermissions [AddRemoveConvMember]
-  mem1T1 <- Util.randomUser
+      pEmpty = Util.symmPermissions []
+  mem1T1 <- addMemberWithPermissions ownerT1 tidT1 p
   qMem1T1 <- Qualified mem1T1 <$> viewFederationDomain
-  mem2T1 <- Util.randomUser
+  mem2T1 <- addMemberWithPermissions ownerT1 tidT1 p
   qMem2T1 <- Qualified mem2T1 <$> viewFederationDomain
-
-  let pEmpty = Util.symmPermissions []
-  mem3T1 <- Util.randomUser
+  mem3T1 <- addMemberWithPermissions ownerT1 tidT1 pEmpty
   qMem3T1 <- Qualified mem3T1 <$> viewFederationDomain
-
-  mem4T1 <- newTeamMember' pEmpty <$> Util.randomUser
-  qMem4T1 <- Qualified (mem4T1 ^. userId) <$> viewFederationDomain
-  (ownerT2, qOwnerT2) <- Util.randomUserTuple
-  mem1T2 <- newTeamMember' p <$> Util.randomUser
-  qMem1T2 <- Qualified (mem1T2 ^. userId) <$> viewFederationDomain
-  Util.connectUsers ownerT1 (mem1T1 :| [mem2T1, mem3T1, ownerT2, personalUser])
-  tidT1 <- createBindingTeamInternal "foo" ownerT1
-  do
-    Util.addTeamMemberInternal tidT1 mem1T1 p Nothing
-    Util.addTeamMemberInternal tidT1 mem2T1 p Nothing
-    Util.addTeamMemberInternal tidT1 mem3T1 pEmpty Nothing
+  mem4T1 <- Util.randomUser
+  qMem4T1 <- Qualified mem4T1 <$> viewFederationDomain
   tidT2 <- Util.createBindingTeamInternal "foo" ownerT2
-  Util.addTeamMemberInternal tidT2 (mem1T2 ^. userId) (mem1T2 ^. permissions) (mem1T2 ^. invitation)
+  mem1T2 <- addMemberWithPermissions ownerT2 tidT2 p
+  qMem1T2 <- Qualified mem1T2 <$> viewFederationDomain
+  -- ownerT1 is connected across teams to ownerT2 and personally to
+  -- personalUser. Same-team members (mem1T1/mem2T1/mem3T1) need no explicit
+  -- connection: same-binding-team connection requests are rejected (403), and
+  -- every assertion involving them is satisfied by the same-team condition.
+  Util.connectUsers ownerT1 (ownerT2 :| [personalUser])
   -- Team owners create new regular team conversation:
   cidT1 <- Util.createTeamConv ownerT1 tidT1 [] (Just "blaa") Nothing Nothing
   qcidT1 <- Qualified cidT1 <$> viewFederationDomain
@@ -791,7 +757,7 @@ testAddTeamMemberToConv = do
   Util.assertConvMember qMem1T2 cidT1
   -- Still, they cannot add random members without a connection from T1, despite the conversation being "hosted" there
   Util.postMembers ownerT2 (pure qMem4T1) qcidT1 !!! const 403 === statusCode
-  Util.assertNotConvMember (mem4T1 ^. userId) cidT1
+  Util.assertNotConvMember mem4T1 cidT1
   -- Now let's look at convs hosted on team2
   -- ownerT2 *is* connected to ownerT1
   Util.postMembers ownerT2 (pure qOwnerT1) qcidT2 !!! const 200 === statusCode
@@ -799,7 +765,7 @@ testAddTeamMemberToConv = do
   -- and mem1T2 is on the same team, but mem1T1 is *not*
   Util.postMembers ownerT2 (qMem1T2 :| [qMem1T1]) qcidT2 !!! const 403 === statusCode
   Util.assertNotConvMember mem1T1 cidT2
-  Util.assertNotConvMember (mem1T2 ^. userId) cidT2
+  Util.assertNotConvMember mem1T2 cidT2
   -- mem1T2 is on the same team, so that is fine too
   Util.postMembers ownerT2 (pure qMem1T2) qcidT2 !!! const 200 === statusCode
   Util.assertConvMember qMem1T2 cidT2
@@ -823,6 +789,12 @@ testAddTeamMemberToConv = do
   -- Users *can* add across teams if *connected*
   Util.postMembers ownerT1 (pure qOwnerT2) qcidPersonal !!! const 200 === statusCode
   Util.assertConvMember qOwnerT2 cidPersonal
+  where
+    addMemberWithPermissions :: UserId -> TeamId -> Permissions -> TestM UserId
+    addMemberWithPermissions inviter tid perms = do
+      mem <- Util.addUserToTeamWithRole (Just RoleMember) inviter tid
+      Util.updateTeamMemberPermissions inviter mem tid perms
+      pure (mem ^. userId)
 
 testUpdateTeamConv ::
   -- | Team role of the user who creates the conversation
@@ -1145,9 +1117,9 @@ testDeleteTeamConv = do
   (tid, owner, _) <- Util.createBindingTeamWithMembers 2
   qOwner <- Qualified owner <$> viewFederationDomain
   let p = Util.symmPermissions [P.DeleteConversation]
-  member <- newTeamMember' p <$> Util.randomUser
+  member <- Util.addUserToTeamWithRole (Just RoleMember) owner tid
   qMember <- Qualified (member ^. userId) <$> viewFederationDomain
-  Util.addTeamMemberInternal tid (member ^. userId) (member ^. permissions) Nothing
+  Util.updateTeamMemberPermissions owner member tid p
   let members = [qOwner, qMember]
   extern <- Util.randomUser
   qExtern <- Qualified extern <$> viewFederationDomain
diff --git a/services/galley/test/integration/API/Teams/LegalHold.hs b/services/galley/test/integration/API/Teams/LegalHold.hs
index db9e3db7d0c..ddb839c296a 100644
--- a/services/galley/test/integration/API/Teams/LegalHold.hs
+++ b/services/galley/test/integration/API/Teams/LegalHold.hs
@@ -108,8 +108,7 @@ data IsWorking = Working | NotWorking
 testCreateLegalHoldTeamSettings :: TestM ()
 testCreateLegalHoldTeamSettings = withTeam $ \owner tid -> do
   putLHWhitelistTeam tid !!! const 200 === statusCode
-  member <- randomUser
-  addTeamMemberInternal tid member (rolePermissions RoleMember) Nothing
+  member <- (^. userId) <$> addUserToTeamWithRole (Just RoleMember) owner tid
   -- Random port, hopefully nothing is runing here!
   brokenService <- newLegalHoldService 4242
   -- not allowed to create if team is not whitelisted
@@ -165,8 +164,8 @@ testCreateLegalHoldTeamSettings = withTeam $ \owner tid -> do
 testRemoveLegalHoldFromTeam :: TestM ()
 testRemoveLegalHoldFromTeam = do
   (owner, tid) <- createBindingTeam
-  member <- randomUser
-  addTeamMemberInternal tid member noPermissions Nothing
+  memberTm <- addUserToTeamWithRole (Just RoleMember) owner tid
+  updateTeamMemberPermissions owner memberTm tid noPermissions
   -- fails if LH for team is disabled
   deleteSettings (Just defPassword) owner tid !!! testResponse 403 (Just "legalhold-disable-unimplemented")
 
@@ -180,12 +179,12 @@ testAddTeamUserTooLargeWithLegalholdWhitelisted = withTeam $ \owner tid -> do
 
 testCannotCreateLegalHoldDeviceOldAPI :: TestM ()
 testCannotCreateLegalHoldDeviceOldAPI = do
-  member <- randomUser
+  nonMember <- randomUser
   (owner, tid) <- createBindingTeam
   -- user without team can't add LH device
-  tryout member
+  tryout nonMember
   -- team member can't add LH device
-  addTeamMemberInternal tid member (rolePermissions RoleMember) Nothing
+  member <- (^. userId) <$> addUserToTeamWithRole (Just RoleMember) owner tid
   tryout member
   -- team owner can't add LH device
   tryout owner
diff --git a/services/galley/test/integration/API/Teams/LegalHold/DisabledByDefault.hs b/services/galley/test/integration/API/Teams/LegalHold/DisabledByDefault.hs
index 734af584d74..580e7d7d8d1 100644
--- a/services/galley/test/integration/API/Teams/LegalHold/DisabledByDefault.hs
+++ b/services/galley/test/integration/API/Teams/LegalHold/DisabledByDefault.hs
@@ -93,8 +93,7 @@ data IsWorking = Working | NotWorking
 testCreateLegalHoldTeamSettings :: TestM ()
 testCreateLegalHoldTeamSettings = do
   (owner, tid) <- createBindingTeam
-  member <- randomUser
-  addTeamMemberInternal tid member (rolePermissions RoleMember) Nothing
+  member <- (^. Team.userId) <$> addUserToTeamWithRole (Just RoleMember) owner tid
   -- Random port, hopefully nothing is runing here!
   brokenService <- newLegalHoldService 4242
   -- not allowed to create if team setting is disabled
@@ -150,8 +149,9 @@ testRemoveLegalHoldFromTeam :: TestM ()
 testRemoveLegalHoldFromTeam = do
   (owner, tid) <- createBindingTeam
   stranger <- randomUser
-  member <- randomUser
-  addTeamMemberInternal tid member noPermissions Nothing
+  memberTm <- addUserToTeamWithRole (Just RoleMember) owner tid
+  updateTeamMemberPermissions owner memberTm tid noPermissions
+  let member = memberTm ^. Team.userId
   -- fails if LH for team is disabled
   deleteSettings (Just defPassword) owner tid !!! testResponse 403 (Just "legalhold-not-enabled")
   withDummyTestServiceForTeam' owner tid $ \lhPort chan -> do
@@ -228,12 +228,12 @@ testAddTeamUserTooLargeWithLegalhold = do
 
 testCannotCreateLegalHoldDeviceOldAPI :: TestM ()
 testCannotCreateLegalHoldDeviceOldAPI = do
-  member <- randomUser
+  nonMember <- randomUser
   (owner, tid) <- createBindingTeam
   -- user without team can't add LH device
-  tryout member
+  tryout nonMember
   -- team member can't add LH device
-  addTeamMemberInternal tid member (rolePermissions RoleMember) Nothing
+  member <- (^. Team.userId) <$> addUserToTeamWithRole (Just RoleMember) owner tid
   tryout member
   -- team owner can't add LH device
   tryout owner
@@ -257,8 +257,7 @@ testCannotCreateLegalHoldDeviceOldAPI = do
 testGetTeamMembersIncludesLHStatus :: TestM ()
 testGetTeamMembersIncludesLHStatus = do
   (owner, tid) <- createBindingTeam
-  member <- randomUser
-  addTeamMemberInternal tid member (rolePermissions RoleMember) Nothing
+  member <- (^. Team.userId) <$> addUserToTeamWithRole (Just RoleMember) owner tid
 
   let findMemberStatus :: [TeamMember] -> Maybe UserLegalHoldStatus
       findMemberStatus ms =
diff --git a/services/galley/test/integration/API/Util.hs b/services/galley/test/integration/API/Util.hs
index 2acefc220a1..845a4f54984 100644
--- a/services/galley/test/integration/API/Util.hs
+++ b/services/galley/test/integration/API/Util.hs
@@ -246,8 +246,7 @@ createBindingTeamWithNMembersWithHandles withHandles n = do
   (owner, tid) <- createBindingTeam
   setHandle owner
   mems <- replicateM n $ do
-    member1 <- randomUser
-    addTeamMemberInternal tid member1 (Team.rolePermissions RoleMember) Nothing
+    member1 <- (^. Team.userId) <$> addUserToTeamWithRole (Just RoleMember) owner tid
     setHandle member1
     pure member1
   pure (owner, tid, mems)
@@ -410,17 +409,6 @@ getTeamMemberInternal tid mid = do
   r <- get (g . paths ["i", "teams", toByteString' tid, "members", toByteString' mid])  TeamId -> UserId -> Permissions -> Maybe (UserId, UTCTimeMillis) -> TestM ()
-addTeamMemberInternal tid muid mperms mmbinv = addTeamMemberInternal' tid muid mperms mmbinv !!! const 200 === statusCode
-
--- | FUTUREWORK: do not use this, it's broken!!  use 'addUserToTeam' instead!  https://wearezeta.atlassian.net/browse/SQSERVICES-471
-addTeamMemberInternal' :: (HasCallStack) => TeamId -> UserId -> Permissions -> Maybe (UserId, UTCTimeMillis) -> TestM ResponseLBS
-addTeamMemberInternal' tid muid mperms mmbinv = do
-  g <- viewGalley
-  let payload = json (mkNewTeamMember muid mperms mmbinv)
-  post (g . paths ["i", "teams", toByteString' tid, "members"] . payload)
-
 addUserToTeam :: (HasCallStack) => UserId -> TeamId -> TestM TeamMember
 addUserToTeam = addUserToTeamWithRole Nothing
 
@@ -465,9 +453,23 @@ addUserToTeamWithSSO hasEmail tid = do
   getTeamMember uid tid uid
 
 makeOwner :: (HasCallStack) => UserId -> TeamMember -> TeamId -> TestM ()
-makeOwner owner mem tid = do
+makeOwner owner mem tid = updateTeamMemberPermissions owner mem tid fullPermissions
+
+-- | Update an existing team member's permissions via the public
+-- @PUT /teams/:tid/members@ endpoint. Use this together with
+-- 'addUserToTeamWithRole' when a test needs a member with custom
+-- 'Permissions' that do not correspond to a 'Role' (the invitation flow
+-- only accepts a 'Role').
+updateTeamMemberPermissions ::
+  (HasCallStack) =>
+  UserId ->
+  TeamMember ->
+  TeamId ->
+  Permissions ->
+  TestM ()
+updateTeamMemberPermissions owner mem tid perms = do
   galley <- viewGalley
-  let changeMember = mkNewTeamMember (mem ^. Team.userId) fullPermissions (mem ^. Team.invitation)
+  let changeMember = mkNewTeamMember (mem ^. Team.userId) perms (mem ^. Team.invitation)
   put
     ( galley
         . paths ["teams", toByteString' tid, "members"]
@@ -687,29 +689,6 @@ updateTeamConv zusr convid upd = do
         . json upd
     )
 
-createOne2OneTeamConv :: UserId -> UserId -> Maybe Text -> TeamId -> TestM ResponseLBS
-createOne2OneTeamConv u1 u2 n tid = do
-  g <- viewGalley
-  let conv =
-        NewConv
-          [u2]
-          []
-          (n >>= checked)
-          mempty
-          Nothing
-          (Just $ ConvTeamInfo tid)
-          Nothing
-          Nothing
-          roleNameWireAdmin
-          BaseProtocolProteusTag
-          GroupConversation
-          False
-          Nothing
-          False
-          Nothing
-          def
-  post $ g . path "/one2one-conversations" . zUser u1 . zConn "conn" . zType "access" . json conv
-
 postConv ::
   UserId ->
   [UserId] ->

From 09b3d02498341888c4f4f1acc297ff1b48c8e4ec Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Fri, 7 Aug 2026 17:43:26 +0200
Subject: [PATCH 074/113] WPB-27857: exclude the initiator from Wire Meetings
 lifecycle events (#5426)

---
 changelog.d/3-bug-fixes/WPB-27857             |  1 +
 integration/test/Test/Meetings.hs             | 62 ++++++++++++++++---
 .../Wire/MeetingsSubsystem/Notification.hs    |  2 +-
 .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 23 ++++++-
 4 files changed, 75 insertions(+), 13 deletions(-)
 create mode 100644 changelog.d/3-bug-fixes/WPB-27857

diff --git a/changelog.d/3-bug-fixes/WPB-27857 b/changelog.d/3-bug-fixes/WPB-27857
new file mode 100644
index 00000000000..e0bd5f68c36
--- /dev/null
+++ b/changelog.d/3-bug-fixes/WPB-27857
@@ -0,0 +1 @@
+Wire Meetings lifecycle events (meeting.create, meeting.update, meeting.delete) are no longer delivered to the user who triggered the action, consistent with how other event types avoid echoing back to the originator. Previously the creator/updater/deleter received their own meeting event.
diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs
index 007f4983779..1733a9b2f91 100644
--- a/integration/test/Test/Meetings.hs
+++ b/integration/test/Test/Meetings.hs
@@ -10,7 +10,7 @@ import qualified Data.Text.Encoding as Text
 import Data.Time.Clock
 import qualified Data.Time.Format as Time
 import MLS.Util
-import Notifications (isConvCreateMeetingNotif, isConvDeleteMeetingNotif, isMeetingCreateNotif, isMeetingDeleteNotif, isMeetingMemberAddNotif, isMeetingUpdateNotif, isMemberJoinNotif, isWelcomeNotif)
+import Notifications (isConvCreateMeetingNotif, isConvDeleteMeetingNotif, isMeetingDeleteNotif, isMeetingMemberAddNotif, isMeetingUpdateNotif, isMemberJoinNotif, isWelcomeNotif)
 import SetupHelpers
 import System.Timeout (timeout)
 import Testlib.Prelude
@@ -32,8 +32,8 @@ testMeetingCreate = do
       assertSuccess resp
       void $ awaitMatch isConvCreateMeetingNotif ws
       m <- getJSON 201 resp
-      createNotif <- awaitMatch isMeetingCreateNotif ws
-      assertMeetingNotif createNotif (m %. "qualified_id")
+      -- meeting.create is not delivered to the initiator (WPB-27857)
+      assertNoEvent 1 ws
       pure m
 
   meeting %. "title" `shouldMatch` ("Team Standup" :: String)
@@ -261,8 +261,8 @@ testMeetingRecurrence = do
   r2 <- withWebSocket owner $ \ws -> do
     resp <- putMeeting owner domain meetingId updatedMeeting
     assertSuccess resp
-    updateNotif <- awaitMatch isMeetingUpdateNotif ws
-    assertMeetingNotif updateNotif (object ["id" .= meetingId, "domain" .= domain])
+    -- meeting.update is not delivered to the initiator (WPB-27857)
+    assertNoEvent 1 ws
     pure resp
 
   updated <- getJSON 200 r2
@@ -506,14 +506,56 @@ testMeetingDelete = do
   withWebSocket owner $ \ws -> do
     deleteMeeting owner domain meetingId >>= assertStatus 200
     void $ awaitMatch isConvDeleteMeetingNotif ws
-    deleteNotif <- awaitMatch isMeetingDeleteNotif ws
-    assertMeetingNotif deleteNotif (object ["id" .= meetingId, "domain" .= domain])
-    -- A meeting conversation must emit `conversation.delete-meeting`, never the
-    -- plain `conversation.delete` (EdConvDelete). After the two events above the
-    -- socket should be quiet; any leaked delete would surface here.
+    -- meeting.delete is not delivered to the initiator (WPB-27857). The
+    -- conversation.delete-meeting event above is the only event the
+    -- initiator receives; the socket should be quiet afterwards.
     assertNoEvent 1 ws
   getMeeting owner domain meetingId >>= assertStatus 404
 
+-- | WPB-27857: the meeting initiator no longer receives its own lifecycle
+-- events, but other members of the meeting conversation still do. At creation
+-- time the conversation's only local member is the creator, so 'meeting.create'
+-- has no other recipient (the multi-member path is covered at the unit level in
+-- "Wire.MeetingsSubsystem.InterpreterSpec"); here we verify that a member who
+-- joined after creation still receives 'meeting.update' and 'meeting.delete'.
+testMeetingLifecycleEventsDeliveredToMembers :: (HasCallStack) => App ()
+testMeetingLifecycleEventsDeliveredToMembers = do
+  (owner, _tid, [participant]) <- createTeam OwnDomain 2
+  ownerClient <- createMLSClient def owner
+  participantClient <- createMLSClient def participant
+  _ <- uploadNewKeyPackage def participantClient
+  now <- liftIO getCurrentTime
+  let startTime = addUTCTime 3600 now
+      endTime = addUTCTime 7200 now
+      newMeeting = defaultMeetingJson "Lifecycle Meeting" startTime endTime []
+
+  meeting <- postMeetings owner newMeeting >>= getJSON 201
+  (meetingId, domain) <- getMeetingIdAndDomain meeting
+
+  -- Add the second team member to the meeting conversation via MLS, so that
+  -- they are among the recipients of subsequent lifecycle events.
+  convQid <- meeting %. "qualified_conversation"
+  conv <- getConversation owner convQid >>= getJSON 200
+  convId <- objConvId conv
+  createGroup def ownerClient convId
+  void $ createAddCommit ownerClient convId [participant] >>= sendAndConsumeCommitBundle
+
+  -- The non-initiator member receives 'meeting.update'; the initiator-exclusion
+  -- half of WPB-27857 is asserted in 'testMeetingRecurrence'.
+  updateNotif <-
+    withWebSocket participant $ \ws -> do
+      putMeeting owner domain meetingId (object ["title" .= "Updated Lifecycle Meeting"]) >>= assertSuccess
+      awaitMatch isMeetingUpdateNotif ws
+  assertMeetingNotif updateNotif (meeting %. "qualified_id")
+
+  -- The non-initiator member receives 'meeting.delete'. The preceding
+  -- 'conversation.delete-meeting' event is expected and skipped by 'awaitMatch'.
+  deleteNotif <-
+    withWebSocket participant $ \ws -> do
+      deleteMeeting owner domain meetingId >>= assertStatus 200
+      awaitMatch isMeetingDeleteNotif ws
+  assertMeetingNotif deleteNotif (meeting %. "qualified_id")
+
 testMeetingDeleteNotFound :: (HasCallStack) => App ()
 testMeetingDeleteNotFound = do
   (owner, _tid, _members) <- createTeam OwnDomain 1
diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs
index 1be138a47f4..33bd75add34 100644
--- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs
+++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs
@@ -54,7 +54,7 @@ mkMeetingEventPush now qUser conn recipients qConvId mTeamId meetingType qMeetin
               evtTime = now,
               evtTeam = mTeamId
             },
-      recipients,
+      recipients = filter ((/= qUnqualified qUser) . recipientUserId) recipients,
       route = PushV2.RouteDirect,
       conn
     }
diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
index 904006b7c87..34573c0df49 100644
--- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
+++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
@@ -52,13 +52,13 @@ import Wire.API.Team.Permission (fullPermissions)
 import Wire.ConversationSubsystem
 import Wire.FeaturesConfigSubsystem
 import Wire.GalleyAPIAccess (GalleyAPIAccess)
-import Wire.MeetingNotifier (MeetingNotifier)
+import Wire.MeetingNotifier (MeetingNotifier, notifyMeetingEvent)
 import Wire.MeetingNotifier.Interpreter (interpretMeetingNotifier)
 import Wire.MeetingsStore qualified as Store
 import Wire.MeetingsSubsystem
 import Wire.MeetingsSubsystem.Interpreter
 import Wire.MockInterpreters
-import Wire.NotificationSubsystem (NotificationSubsystem, Push (..))
+import Wire.NotificationSubsystem (NotificationSubsystem, Push (..), Recipient (recipientUserId))
 import Wire.Sem.Logger.TinyLog (discardTinyLogs)
 import Wire.Sem.Now (Now)
 import Wire.Sem.Random (Random)
@@ -1452,6 +1452,25 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
         Left err -> fail $ "Error: " <> show err
         Right pushes -> extractMeetingEvents pushes `shouldBe` []
 
+    it "does not deliver lifecycle events to the meeting initiator" $ do
+      let members =
+            [ newMember uid1,
+              newMember uid2
+            ]
+          meetingId = Id $ read "00000000-0000-0000-0000-000000000070"
+          convId = Id $ read "00000000-0000-0000-0000-000000000071"
+          qMeetingId = Qualified meetingId (Domain "wire.com")
+          qConvId = Qualified convId (Domain "wire.com")
+      result <-
+        runTestStack now gen Map.empty def $ do
+          _ <- notifyMeetingEvent zUser1 Nothing members qConvId Nothing MeetingEvent.Create qMeetingId
+          get @[Push]
+      case result of
+        Left err -> fail $ "Error: " <> show err
+        Right pushes -> do
+          let recipientIds = map (.recipientUserId) (concatMap (.recipients) pushes)
+          recipientIds `shouldBe` [uid2]
+
 -- | Synchronize with 'Wire.MeetingsSubsystem.Interpreter.startTimeTolerance'
 expectedStartTimeTolerance :: NominalDiffTime
 expectedStartTimeTolerance = 60

From d6b0d6abf300855c2342d7c94f63e0755fd7de60 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Mon, 10 Aug 2026 10:42:08 +0200
Subject: [PATCH 075/113] fix(federator): cross-check external listener in
 internal /i/status (#5403)

* fix(integration): poll federatorExternal in checkServiceIsUp

When starting dynamic backends, waitUntilServiceIsUp calls checkServiceIsUp to verify each service is up and running by polling /i/status.

For FederatorInternal, checkServiceIsUp only polled the internal HTTP listener (port 10097). Because FederatorInternal and FederatorExternal are launched in separate asynchronous threads, waitUntilServiceIsUp could return as soon as port 10097 bound, leaving a window where FederatorExternal (port 10098) was not yet listening for incoming HTTP/2 federation requests.

Subsequent probes to FederatorExternal during this startup window failed or hit fallback endpoints (such as /i/metrics), causing non-JSON responses to be returned and triggering assertion failures in checkFederationIngress.

This commit updates checkServiceIsUp to poll both FederatorInternal and FederatorExternal when checking FederatorInternal, ensuring both HTTP listeners are ready before waitUntilServiceIsUp completes.

* fix(federator): cross-check external listener in internal /i/status

serveOutward (the federator internal listener launcher) passed
env._internalPort to its `server`, but that argument is the port the
listener's /i/status health check cross-polls (Federator/Health.hs). The
internal listener therefore cross-checked itself (port 10097) instead of
the external listener (port 10098), failing to mirror the correct
symmetric cross-check that serveInward already performs. During
dynamic-backend startup this let checkServiceIsUp(FederatorInternal)
report ready before 10098 was bound, causing flaky checkFederationIngress
failures.

Pass env._externalPort instead, restoring the cross-check symmetry.
Reverts the now-redundant checkServiceIsUp workaround and adds a
changelog entry.

* fix(integration): restore FederatorExternal poll in checkServiceIsUp

Reverting the workaround re-exposed the dynamic-backend startup race:
checkFederationIngress probes returned the federator metrics fallback
instead of JSON. The production cross-check fix verifies the external
listener via localhost inside the container, but the federation call
reaches dynamic backends via the envoy service path, which has a
separate readiness delay the cross-check cannot observe. Re-apply the
FederatorExternal service-address poll that covers that path; keep the
production fix.
---
 .../federator-internal-status-cross-check         |  1 +
 integration/test/Testlib/ModService.hs            | 15 ++++++++++++++-
 .../federator/src/Federator/InternalServer.hs     |  2 +-
 3 files changed, 16 insertions(+), 2 deletions(-)
 create mode 100644 changelog.d/3-bug-fixes/federator-internal-status-cross-check

diff --git a/changelog.d/3-bug-fixes/federator-internal-status-cross-check b/changelog.d/3-bug-fixes/federator-internal-status-cross-check
new file mode 100644
index 00000000000..9e910dc833c
--- /dev/null
+++ b/changelog.d/3-bug-fixes/federator-internal-status-cross-check
@@ -0,0 +1 @@
+The federator internal listener's /i/status health check now correctly verifies the external listener is ready instead of checking itself, so readiness no longer reports up before the external federation listener is bound.
diff --git a/integration/test/Testlib/ModService.hs b/integration/test/Testlib/ModService.hs
index 0c4f7d0cc35..a02f921639a 100644
--- a/integration/test/Testlib/ModService.hs
+++ b/integration/test/Testlib/ModService.hs
@@ -516,9 +516,22 @@ checkServiceIsUp :: String -> Service -> App Bool
 checkServiceIsUp _ Nginz = pure True
 checkServiceIsUp domain srv = do
   req <- baseRequest domain srv Unversioned "/i/status"
+  mExtReq <- case srv of
+    FederatorInternal -> do
+      sMap <- getServiceMap domain
+      let extHostPort = sMap.federatorExternal
+          extUrl = "http://" <> extHostPort.host <> ":" <> show extHostPort.port <> "/i/status"
+      Just <$> externalRequest extUrl
+    _ -> pure Nothing
   checkStatus <- appToIO $ do
     res <- submit "GET" req
-    pure (res.status `elem` [200, 204])
+    if res.status `elem` [200, 204]
+      then case mExtReq of
+        Just extReq -> do
+          extRes <- submit "GET" extReq
+          pure (extRes.status `elem` [200, 204])
+        Nothing -> pure True
+      else pure False
   eith <- liftIO (E.try checkStatus)
   pure $ either (\(_e :: HTTP.HttpException) -> False) id eith
 
diff --git a/services/federator/src/Federator/InternalServer.hs b/services/federator/src/Federator/InternalServer.hs
index accd160987d..e57d40354e3 100644
--- a/services/federator/src/Federator/InternalServer.hs
+++ b/services/federator/src/Federator/InternalServer.hs
@@ -131,4 +131,4 @@ callOutward targetDomain component (RPC path) req cont = do
 
 serveOutward :: Env -> Int -> IORef [IO ()] -> IO ()
 serveOutward env port cleanupsRef = do
-  serveServant @(ToServantApi API) env port cleanupsRef (toServant $ server env._httpManager env._internalPort)
+  serveServant @(ToServantApi API) env port cleanupsRef (toServant $ server env._httpManager env._externalPort)

From a7eb92f4605f45b28ca48abc1d5f0e1c21b78cf8 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Mon, 10 Aug 2026 16:49:39 +0200
Subject: [PATCH 076/113] WPB-27907: filter Wire Meetings events only on
 connection (#5428)

---
 .../WPB-27907-meeting-notifications-alignment |   1 +
 integration/test/Test/Meetings.hs             |  49 +++--
 .../Wire/API/Routes/Public/Galley/Meetings.hs |   4 +
 .../src/Wire/MeetingNotifier/Interpreter.hs   |   4 +
 .../src/Wire/MeetingsSubsystem.hs             |   2 +
 .../src/Wire/MeetingsSubsystem/Interpreter.hs |  18 +-
 .../Wire/MeetingsSubsystem/Notification.hs    |   2 +-
 .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 169 +++++++++---------
 services/galley/src/Galley/API/Meetings.hs    |   8 +-
 9 files changed, 146 insertions(+), 111 deletions(-)
 create mode 100644 changelog.d/2-features/WPB-27907-meeting-notifications-alignment

diff --git a/changelog.d/2-features/WPB-27907-meeting-notifications-alignment b/changelog.d/2-features/WPB-27907-meeting-notifications-alignment
new file mode 100644
index 00000000000..01a8285f755
--- /dev/null
+++ b/changelog.d/2-features/WPB-27907-meeting-notifications-alignment
@@ -0,0 +1 @@
+Filter Wire Meetings lifecycle events only on the originating client connection.
diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs
index 1733a9b2f91..314a89cf1a2 100644
--- a/integration/test/Test/Meetings.hs
+++ b/integration/test/Test/Meetings.hs
@@ -10,7 +10,7 @@ import qualified Data.Text.Encoding as Text
 import Data.Time.Clock
 import qualified Data.Time.Format as Time
 import MLS.Util
-import Notifications (isConvCreateMeetingNotif, isConvDeleteMeetingNotif, isMeetingDeleteNotif, isMeetingMemberAddNotif, isMeetingUpdateNotif, isMemberJoinNotif, isWelcomeNotif)
+import Notifications (isConvCreateMeetingNotif, isConvDeleteMeetingNotif, isMeetingCreateNotif, isMeetingDeleteNotif, isMeetingMemberAddNotif, isMeetingUpdateNotif, isMemberJoinNotif, isWelcomeNotif)
 import SetupHelpers
 import System.Timeout (timeout)
 import Testlib.Prelude
@@ -32,8 +32,8 @@ testMeetingCreate = do
       assertSuccess resp
       void $ awaitMatch isConvCreateMeetingNotif ws
       m <- getJSON 201 resp
-      -- meeting.create is not delivered to the initiator (WPB-27857)
-      assertNoEvent 1 ws
+      -- the creator's other client connection (this websocket) now receives meeting.create
+      void $ awaitMatch isMeetingCreateNotif ws
       pure m
 
   meeting %. "title" `shouldMatch` ("Team Standup" :: String)
@@ -261,8 +261,8 @@ testMeetingRecurrence = do
   r2 <- withWebSocket owner $ \ws -> do
     resp <- putMeeting owner domain meetingId updatedMeeting
     assertSuccess resp
-    -- meeting.update is not delivered to the initiator (WPB-27857)
-    assertNoEvent 1 ws
+    -- the creator's other client connection (this websocket) now receives meeting.update
+    void $ awaitMatch isMeetingUpdateNotif ws
     pure resp
 
   updated <- getJSON 200 r2
@@ -506,18 +506,18 @@ testMeetingDelete = do
   withWebSocket owner $ \ws -> do
     deleteMeeting owner domain meetingId >>= assertStatus 200
     void $ awaitMatch isConvDeleteMeetingNotif ws
-    -- meeting.delete is not delivered to the initiator (WPB-27857). The
-    -- conversation.delete-meeting event above is the only event the
-    -- initiator receives; the socket should be quiet afterwards.
-    assertNoEvent 1 ws
+    -- the creator's other client connection (this websocket) now receives meeting.delete
+    void $ awaitMatch isMeetingDeleteNotif ws
   getMeeting owner domain meetingId >>= assertStatus 404
 
--- | WPB-27857: the meeting initiator no longer receives its own lifecycle
--- events, but other members of the meeting conversation still do. At creation
--- time the conversation's only local member is the creator, so 'meeting.create'
--- has no other recipient (the multi-member path is covered at the unit level in
--- "Wire.MeetingsSubsystem.InterpreterSpec"); here we verify that a member who
--- joined after creation still receives 'meeting.update' and 'meeting.delete'.
+-- | WPB-27907: meeting lifecycle events are delivered to all conversation
+-- members; only the originating client connection is excluded (no redundant
+-- echo), while the initiator's other client connections and all other members
+-- receive them. This test covers the non-initiator member path: a member who
+-- joined the meeting conversation after creation receives 'meeting.update' and
+-- 'meeting.delete'. The originating-client-connection exclusion is covered in
+-- 'testMeetingOriginatingConnectionExcluded'; the multi-member recipient set
+-- is covered at the unit level in "Wire.MeetingsSubsystem.InterpreterSpec".
 testMeetingLifecycleEventsDeliveredToMembers :: (HasCallStack) => App ()
 testMeetingLifecycleEventsDeliveredToMembers = do
   (owner, _tid, [participant]) <- createTeam OwnDomain 2
@@ -540,8 +540,8 @@ testMeetingLifecycleEventsDeliveredToMembers = do
   createGroup def ownerClient convId
   void $ createAddCommit ownerClient convId [participant] >>= sendAndConsumeCommitBundle
 
-  -- The non-initiator member receives 'meeting.update'; the initiator-exclusion
-  -- half of WPB-27857 is asserted in 'testMeetingRecurrence'.
+  -- The non-initiator member receives 'meeting.update'; the originating-
+  -- client-connection exclusion is asserted in 'testMeetingOriginatingConnectionExcluded'.
   updateNotif <-
     withWebSocket participant $ \ws -> do
       putMeeting owner domain meetingId (object ["title" .= "Updated Lifecycle Meeting"]) >>= assertSuccess
@@ -556,6 +556,21 @@ testMeetingLifecycleEventsDeliveredToMembers = do
       awaitMatch isMeetingDeleteNotif ws
   assertMeetingNotif deleteNotif (meeting %. "qualified_id")
 
+testMeetingOriginatingConnectionExcluded :: (HasCallStack) => App ()
+testMeetingOriginatingConnectionExcluded = do
+  (owner, _tid, _members) <- createTeam OwnDomain 1
+  now <- liftIO getCurrentTime
+  let newMeeting = defaultMeetingJson "Origin Conn" (addUTCTime 3600 now) (addUTCTime 7200 now) []
+  withWebSocket (owner, "conn") $ \wsOrigin ->
+    withWebSocket owner $ \wsOther -> do
+      _ <- postMeetings owner newMeeting >>= assertSuccess
+      void $ awaitMatch isMeetingCreateNotif wsOther
+      -- The originating client connection still receives conversation.create-meeting
+      -- (the conversation event is not client-connection-excluded), but it must NOT
+      -- receive meeting.create (excluded via the request's client connection "conn").
+      void $ awaitMatch isConvCreateMeetingNotif wsOrigin
+      assertNoEvent 1 wsOrigin
+
 testMeetingDeleteNotFound :: (HasCallStack) => App ()
 testMeetingDeleteNotFound = do
   (owner, _tid, _members) <- createTeam OwnDomain 1
diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs
index a58380eefba..f692e2ab525 100644
--- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs
+++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs
@@ -36,6 +36,7 @@ type MeetingsAPI =
         :> From 'V15
         :> Until 'V17
         :> ZLocalUser
+        :> ZConn
         :> "meetings"
         :> ReqBody '[JSON] NewMeeting
         :> CanThrow 'InvalidOperation
@@ -51,6 +52,7 @@ type MeetingsAPI =
            ( Summary "Create a new meeting"
                :> From 'V17
                :> ZLocalUser
+               :> ZConn
                :> "meetings"
                :> ReqBody '[JSON] NewMeeting
                :> CanThrow 'InvalidOperation
@@ -67,6 +69,7 @@ type MeetingsAPI =
                :> From 'V15
                :> Until 'V17
                :> ZLocalUser
+               :> ZConn
                :> "meetings"
                :> Capture "domain" Domain
                :> Capture "id" MeetingId
@@ -85,6 +88,7 @@ type MeetingsAPI =
            ( Summary "Update an existing meeting"
                :> From 'V17
                :> ZLocalUser
+               :> ZConn
                :> "meetings"
                :> Capture "domain" Domain
                :> Capture "id" MeetingId
diff --git a/libs/wire-subsystems/src/Wire/MeetingNotifier/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingNotifier/Interpreter.hs
index 38d262d3f15..53ee3bbed38 100644
--- a/libs/wire-subsystems/src/Wire/MeetingNotifier/Interpreter.hs
+++ b/libs/wire-subsystems/src/Wire/MeetingNotifier/Interpreter.hs
@@ -70,6 +70,10 @@ notifyMeetingMembersAddedImpl qUser qConvId mTeamId users = do
     TinyLog.warn $
       Log.msg ("alive meeting not found for meeting member-add event" :: ByteString)
         . Log.field "conversationId" (toByteString' (qUnqualified qConvId))
+  -- `users` are the members added by the commit; the commit creator is already
+  -- a member and never in `users`, so mkMeetingEventPush (which no longer
+  -- filters the originator by UserId) does not echo member-add back to them.
+  -- conn is Nothing: every client connection of each added user should be notified.
   for_ meetings $ \meeting ->
     pushNotificationAsync $
       mkMeetingEventPush
diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs
index ac3bcced59f..de92cab728b 100644
--- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs
+++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs
@@ -30,10 +30,12 @@ import Wire.API.User.EmailAddress (EmailAddress)
 data MeetingsSubsystem m a where
   CreateMeeting ::
     Local UserId ->
+    ConnId ->
     NewMeeting ->
     MeetingsSubsystem m MeetingWithConversation
   UpdateMeeting ::
     Local UserId ->
+    ConnId ->
     Qualified MeetingId ->
     UpdateMeeting ->
     MeetingsSubsystem m (Maybe MeetingWithConversation)
diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
index 0b45cb2d855..0f6e0adc0d3 100644
--- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
+++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
@@ -114,10 +114,10 @@ interpretMeetingsSubsystem ::
   NominalDiffTime ->
   InterpreterFor MeetingsSubsystem r
 interpretMeetingsSubsystem validityPeriod = interpret $ \case
-  CreateMeeting zUser newMeeting ->
-    createMeetingImpl zUser newMeeting
-  UpdateMeeting zUser meetingId update ->
-    updateMeetingImpl zUser meetingId update validityPeriod
+  CreateMeeting zUser connId newMeeting ->
+    createMeetingImpl zUser connId newMeeting
+  UpdateMeeting zUser connId meetingId update ->
+    updateMeetingImpl zUser connId meetingId update validityPeriod
   DeleteMeeting zUser connId meetingId ->
     deleteMeetingImpl zUser connId meetingId validityPeriod
   GetMeeting zUser meetingId ->
@@ -143,9 +143,10 @@ createMeetingImpl ::
     Member (Error MeetingError) r
   ) =>
   Local UserId ->
+  ConnId ->
   API.NewMeeting ->
   Sem r API.MeetingWithConversation
-createMeetingImpl zUser newMeeting = do
+createMeetingImpl zUser connId newMeeting = do
   -- Look up user's team once and reuse for both checks
   conversationTeamId <- TeamSubsystem.internalGetOneUserTeam (tUnqualified zUser)
   checkMeetingsEnabled conversationTeamId
@@ -201,7 +202,7 @@ createMeetingImpl zUser newMeeting = do
       trial
 
   let qMeetingId = Qualified storedMeeting.id (tDomain zUser)
-  notifyMeetingEvent zUser Nothing storedConv.localMembers (Qualified storedConv.id_ (tDomain zUser)) conversationTeamId MeetingEvent.Create qMeetingId
+  notifyMeetingEvent zUser (Just connId) storedConv.localMembers (Qualified storedConv.id_ (tDomain zUser)) conversationTeamId MeetingEvent.Create qMeetingId
 
   pure $ storedMeetingToMeetingWithConversation zUser storedConv storedMeeting
 
@@ -216,11 +217,12 @@ updateMeetingImpl ::
     Member Now r
   ) =>
   Local UserId ->
+  ConnId ->
   Qualified MeetingId ->
   API.UpdateMeeting ->
   NominalDiffTime ->
   Sem r (Maybe API.MeetingWithConversation)
-updateMeetingImpl zUser meetingId update validityPeriod = do
+updateMeetingImpl zUser connId meetingId update validityPeriod = do
   maybeTeamId <- TeamSubsystem.internalGetOneUserTeam (tUnqualified zUser)
   checkMeetingsEnabled maybeTeamId
   when (isNothing update.title && isNothing update.startTime && isNothing update.endTime && isNothing update.recurrence) $
@@ -256,7 +258,7 @@ updateMeetingImpl zUser meetingId update validityPeriod = do
           update.endTime
           update.recurrence
     conv <- MaybeT $ getMeetingConversationOrFail meetingId updatedMeeting.conversationId
-    lift $ notifyMeetingEvent zUser Nothing conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId MeetingEvent.Update meetingId
+    lift $ notifyMeetingEvent zUser (Just connId) conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId MeetingEvent.Update meetingId
     pure $ storedMeetingToMeetingWithConversation zUser conv updatedMeeting
 
 deleteMeetingImpl ::
diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs
index 33bd75add34..7eb3ad27faa 100644
--- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs
+++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs
@@ -54,7 +54,7 @@ mkMeetingEventPush now qUser conn recipients qConvId mTeamId meetingType qMeetin
               evtTime = now,
               evtTeam = mTeamId
             },
-      recipients = filter ((/= qUnqualified qUser) . recipientUserId) recipients,
+      recipients = recipients,
       route = PushV2.RouteDirect,
       conn
     }
diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
index 34573c0df49..11e968bfc06 100644
--- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
+++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
@@ -163,7 +163,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             }
 
     result <- runTestStack now gen Map.empty def $ do
-      meeting <- createMeeting zUser newMeeting
+      meeting <- createMeeting zUser (ConnId "test-conn") newMeeting
       fetched <- getMeeting zUser meeting.meeting.id
       pure (meeting, fetched)
 
@@ -189,7 +189,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             }
 
     result <- runTestStack now gen Map.empty def $ do
-      meeting <- createMeeting zUser newMeeting
+      meeting <- createMeeting zUser (ConnId "test-conn") newMeeting
       pure meeting.conversation.metadata.cnvmAccess
 
     case result of
@@ -212,7 +212,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               invitedEmails = []
             }
 
-    result <- runTestStack now gen Map.empty def $ createMeeting zUser newMeeting
+    result <- runTestStack now gen Map.empty def $ createMeeting zUser (ConnId "test-conn") newMeeting
     result `shouldBe` Left InvalidTimes
 
   it "fails to create a meeting if start time is in the past" $ do
@@ -229,7 +229,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               invitedEmails = []
             }
 
-    result <- runTestStack now gen Map.empty def $ createMeeting zUser newMeeting
+    result <- runTestStack now gen Map.empty def $ createMeeting zUser (ConnId "test-conn") newMeeting
     result `shouldBe` Left InvalidTimes
 
   it "accepts a meeting whose start time is exactly at the tolerance boundary" $ do
@@ -248,7 +248,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               invitedEmails = []
             }
 
-    result <- runTestStack now gen Map.empty def $ createMeeting zUser newMeeting
+    result <- runTestStack now gen Map.empty def $ createMeeting zUser (ConnId "test-conn") newMeeting
     result `shouldSatisfy` isRight
 
   it "rejects a meeting whose start time is just past the tolerance boundary" $ do
@@ -266,7 +266,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               invitedEmails = []
             }
 
-    result <- runTestStack now gen Map.empty def $ createMeeting zUser newMeeting
+    result <- runTestStack now gen Map.empty def $ createMeeting zUser (ConnId "test-conn") newMeeting
     result `shouldBe` Left InvalidTimes
 
   describe "getMeeting access control" $ do
@@ -294,7 +294,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         passTime validityWindow
         getMeeting zUser1 meeting.meeting.id
 
@@ -311,7 +311,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         (meeting,) <$> getMeeting zUser1 meeting.meeting.id
 
       case result of
@@ -330,7 +330,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         members <- gets (Map.lookup (qUnqualified meeting.conversation.qualifiedId))
         let updatedMembers = maybe (Set.singleton uid2) (Set.insert uid2) members
         modify (Map.insert (qUnqualified meeting.conversation.qualifiedId) updatedMembers)
@@ -352,7 +352,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         getMeeting zUser3 meeting.meeting.id
 
       result `shouldBe` Right Nothing
@@ -381,8 +381,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
-        updateMeeting zUser1 meeting.meeting.id (API.UpdateMeeting Nothing Nothing Nothing Nothing)
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
+        updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing Nothing Nothing)
 
       result `shouldBe` Left EmptyUpdate
 
@@ -397,7 +397,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         let update =
               API.UpdateMeeting
                 { startTime = Just (addUTCTime 8000 now),
@@ -405,7 +405,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
                   title = Nothing,
                   recurrence = Nothing
                 }
-        updateMeeting zUser1 meeting.meeting.id update
+        updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update
 
       result `shouldBe` Left InvalidTimes
 
@@ -420,7 +420,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         let update =
               API.UpdateMeeting
                 { startTime = Just (addUTCTime (negate 3600) now),
@@ -428,7 +428,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
                   title = Nothing,
                   recurrence = Nothing
                 }
-        updateMeeting zUser1 meeting.meeting.id update
+        updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update
 
       result `shouldBe` Left InvalidTimes
 
@@ -443,7 +443,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
       result <-
         runTestStack now gen Map.empty teamConfig $ do
-          meeting <- createMeeting zUser1 ongoingMeeting
+          meeting <- createMeeting zUser1 (ConnId "test-conn") ongoingMeeting
           -- Advance the clock 3000s: startTime (now+100s) is now in the past, so
           -- the meeting has started. It stays editable because isAlive is
           -- endTime-based and endTime (now+7200s) is still well past the
@@ -457,7 +457,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
                     title = Just (unsafeRange "Edited While Ongoing"),
                     recurrence = Nothing
                   }
-          updateMeeting zUser1 meeting.meeting.id update
+          updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update
       case result of
         Left err ->
           fail $ "Expected the ongoing meeting to be editable, got: " <> show err
@@ -476,7 +476,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
       result <-
         runTestStack now gen Map.empty teamConfig $ do
-          meeting <- createMeeting zUser1 ongoingMeeting
+          meeting <- createMeeting zUser1 (ConnId "test-conn") ongoingMeeting
           passTime 3000
           let update =
                 API.UpdateMeeting
@@ -485,7 +485,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
                     title = Nothing,
                     recurrence = Nothing
                   }
-          updateMeeting zUser1 meeting.meeting.id update
+          updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update
       result `shouldBe` Left InvalidTimes
 
     it "returns Nothing for expired meeting" $ do
@@ -499,9 +499,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         passTime validityWindow
-        updateMeeting zUser1 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing)
+        updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing)
 
       result `shouldBe` Right Nothing
 
@@ -516,8 +516,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
-        updateMeeting zUser2 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing)
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
+        updateMeeting zUser2 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing)
 
       result `shouldBe` Right Nothing
 
@@ -532,10 +532,10 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         -- Simulate a data-inconsistency: the meeting's conversation vanished.
         modify @(Map ConvId StoredConversation) (Map.delete (qUnqualified meeting.meeting.conversationId))
-        updateMeeting zUser1 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing)
+        updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing)
 
       result `shouldBe` Right Nothing
 
@@ -564,8 +564,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
        in isNotEmpty && hasValidTimes ==>
             ioProperty $ do
               result <- runTestStack now gen Map.empty teamConfig $ do
-                meeting <- createMeeting zUser1 baseMeeting
-                updated <- updateMeeting zUser1 meeting.meeting.id sanitizedUpdate
+                meeting <- createMeeting zUser1 (ConnId "test-conn") baseMeeting
+                updated <- updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id sanitizedUpdate
                 pure (meeting.meeting.conversationId, updated)
               case result of
                 Left err ->
@@ -605,7 +605,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         deleteResult <- deleteMeeting zUser1 testConnId meeting.meeting.id
         getResult <- getMeeting zUser1 meeting.meeting.id
         pure (deleteResult, getResult)
@@ -623,7 +623,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         deleteMeeting zUser2 testConnId meeting.meeting.id
 
       result `shouldBe` Right False
@@ -639,7 +639,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         passTime validityWindow
         deleteMeeting zUser1 testConnId meeting.meeting.id
 
@@ -664,7 +664,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         _ <- internalGetConversation (qUnqualified meeting.conversation.qualifiedId)
         _ <- deleteMeeting zUser1 testConnId meeting.meeting.id
         internalGetConversation (qUnqualified meeting.conversation.qualifiedId)
@@ -682,7 +682,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         -- Change conversation type to non-meeting by updating local members only
         -- This simulates a non-meeting conversation without touching internal types
         deleteMeeting zUser1 testConnId meeting.meeting.id
@@ -715,7 +715,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         success <- addInvitedEmails zUser1 meeting.meeting.id [email1, email2]
         fetched <- getMeeting zUser1 meeting.meeting.id
         pure (success, fetched)
@@ -738,7 +738,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         passTime validityWindow
         addInvitedEmails zUser1 meeting.meeting.id [email1]
 
@@ -755,7 +755,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         addInvitedEmails zUser2 meeting.meeting.id [email1]
 
       result `shouldBe` Right False
@@ -796,7 +796,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         success <- removeInvitedEmails zUser1 meeting.meeting.id [email2]
         fetched <- getMeeting zUser1 meeting.meeting.id
         pure (success, fetched)
@@ -819,7 +819,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         success <- removeInvitedEmails zUser1 meeting.meeting.id [email1, email2]
         fetched <- getMeeting zUser1 meeting.meeting.id
         pure (success, fetched)
@@ -842,7 +842,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         success <- removeInvitedEmails zUser1 meeting.meeting.id [email2, email3]
         fetched <- getMeeting zUser1 meeting.meeting.id
         pure (success, fetched)
@@ -865,7 +865,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         passTime validityWindow
         removeInvitedEmails zUser1 meeting.meeting.id [email1]
 
@@ -882,7 +882,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         removeInvitedEmails zUser2 meeting.meeting.id [email1]
 
       result `shouldBe` Right False
@@ -923,7 +923,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         success <- replaceInvitedEmails zUser1 meeting.meeting.id [email3]
         fetched <- getMeeting zUser1 meeting.meeting.id
         pure (success, fetched)
@@ -946,7 +946,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         success <- replaceInvitedEmails zUser1 meeting.meeting.id []
         fetched <- getMeeting zUser1 meeting.meeting.id
         pure (success, fetched)
@@ -969,7 +969,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         success <- replaceInvitedEmails zUser1 meeting.meeting.id [email3, email3, email1]
         fetched <- getMeeting zUser1 meeting.meeting.id
         pure (success, fetched)
@@ -992,7 +992,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen Map.empty teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         passTime validityWindow
         replaceInvitedEmails zUser1 meeting.meeting.id [email2]
 
@@ -1009,7 +1009,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               }
 
       result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do
-        meeting <- createMeeting zUser1 newMeeting
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         replaceInvitedEmails zUser2 meeting.meeting.id [email3]
 
       result `shouldBe` Right False
@@ -1073,7 +1073,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "getMeeting returns a recurring meeting whose slot passed but window is open" $ do
       result <-
         runTestStack now gen Map.empty teamConfig $ do
-          meeting <- createMeeting zUser (futureMeeting boundedRecurrence)
+          meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence)
           passTime validityWindow
           getMeeting zUser meeting.meeting.id
       case result of
@@ -1084,7 +1084,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "listMeetings includes a recurring meeting whose slot passed" $ do
       result <-
         runTestStack now gen Map.empty teamConfig $ do
-          _meeting <- createMeeting zUser (futureMeeting boundedRecurrence)
+          _meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence)
           passTime validityWindow
           listMeetings zUser
       case result of
@@ -1094,15 +1094,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "updateMeeting succeeds on a recurring meeting whose slot passed" $ do
       result <-
         runTestStack now gen Map.empty teamConfig $ do
-          meeting <- createMeeting zUser (futureMeeting boundedRecurrence)
+          meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence)
           passTime validityWindow
-          updateMeeting zUser meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing)
+          updateMeeting zUser (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing)
       fmap isJust result `shouldBe` Right True
 
     it "addInvitedEmails succeeds on a recurring meeting whose slot passed" $ do
       result <-
         runTestStack now gen Map.empty teamConfig $ do
-          meeting <- createMeeting zUser (futureMeeting boundedRecurrence)
+          meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence)
           passTime validityWindow
           addInvitedEmails zUser meeting.meeting.id [unsafeEmailAddress "user" "example.com"]
       result `shouldBe` Right True
@@ -1110,7 +1110,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "deleteMeeting succeeds on a recurring meeting whose slot passed" $ do
       result <-
         runTestStack now gen Map.empty teamConfig $ do
-          meeting <- createMeeting zUser (futureMeeting boundedRecurrence)
+          meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence)
           passTime validityWindow
           deleteMeeting zUser (ConnId "test-conv") meeting.meeting.id
       result `shouldBe` Right True
@@ -1118,7 +1118,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "removeInvitedEmails succeeds on a recurring meeting whose slot passed" $ do
       result <-
         runTestStack now gen Map.empty teamConfig $ do
-          meeting <- createMeeting zUser (futureMeeting boundedRecurrence)
+          meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence)
           passTime validityWindow
           removeInvitedEmails zUser meeting.meeting.id [unsafeEmailAddress "user" "example.com"]
       result `shouldBe` Right True
@@ -1126,7 +1126,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "replaceInvitedEmails succeeds on a recurring meeting whose slot passed" $ do
       result <-
         runTestStack now gen Map.empty teamConfig $ do
-          meeting <- createMeeting zUser (futureMeeting boundedRecurrence)
+          meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence)
           passTime validityWindow
           replaceInvitedEmails zUser meeting.meeting.id [unsafeEmailAddress "user" "example.com"]
       result `shouldBe` Right True
@@ -1134,7 +1134,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "getMeeting returns an open-ended recurring meeting indefinitely" $ do
       result <-
         runTestStack now gen Map.empty teamConfig $ do
-          meeting <- createMeeting zUser (futureMeeting openEndedRecurrence)
+          meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting openEndedRecurrence)
           passTime validityWindow
           getMeeting zUser meeting.meeting.id
       fmap isJust result `shouldBe` Right True
@@ -1142,8 +1142,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "cleanupOldMeetings skips recurring meetings whose window is still open" $ do
       result <-
         runTestStack now gen Map.empty teamConfig $ do
-          recurring <- createMeeting zUser (futureMeeting boundedRecurrence)
-          _plain <- createMeeting zUser (futureMeeting Nothing)
+          recurring <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence)
+          _plain <- createMeeting zUser (ConnId "test-conn") (futureMeeting Nothing)
           passTime validityWindow
           -- cutoff is past the endTime (now+7200) so the non-recurring
           -- meeting is picked up, but well before the recurrence window.
@@ -1160,7 +1160,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "cleanupOldMeetings never picks up open-ended recurring meetings" $ do
       result <-
         runTestStack now gen Map.empty teamConfig $ do
-          meeting <- createMeeting zUser (futureMeeting openEndedRecurrence)
+          meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting openEndedRecurrence)
           passTime validityWindow
           -- Even with a cutoff well past the endTime, open-ended
           -- recurrence is never picked up.
@@ -1177,9 +1177,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
       result <-
         runTestStack now gen Map.empty teamConfig $ do
           -- endTime now+8000, no recurrence -> effectiveEndTime now+8000 (earliest)
-          plain <- createMeeting zUser (meetingAt 8000 Nothing)
+          plain <- createMeeting zUser (ConnId "test-conn") (meetingAt 8000 Nothing)
           -- endTime now+4000, until now+10000 -> effectiveEndTime now+10000 (later)
-          recur <- createMeeting zUser (meetingAt 4000 (recurUntil (addUTCTime 10000 now)))
+          recur <- createMeeting zUser (ConnId "test-conn") (meetingAt 4000 (recurUntil (addUTCTime 10000 now)))
           _deleted <- cleanupOldMeetings (addUTCTime 11000 now) 1
           plainRemains <- isJust <$> getMeeting zUser plain.meeting.id
           recurRemains <- isJust <$> getMeeting zUser recur.meeting.id
@@ -1213,7 +1213,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
          in ioProperty $ do
               result <-
                 runTestStack now gen Map.empty teamConfig $ do
-                  meeting <- createMeeting zUser nm
+                  meeting <- createMeeting zUser (ConnId "test-conn") nm
                   passTime advanceTime
                   fetched <- isJust <$> getMeeting zUser meeting.meeting.id
                   listedCount <- length <$> listMeetings zUser
@@ -1253,28 +1253,28 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "allows operations for personal user even when meetings disabled" $ do
       result <-
         runTestStack now gen Map.empty meetingsDisabled $
-          createMeeting zUserPersonal newMeeting
+          createMeeting zUserPersonal (ConnId "test-conn") newMeeting
 
       result `shouldSatisfy` isRight
 
     it "allows operations for team user with meetings enabled" $ do
       result <-
         runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $
-          createMeeting zUserTeam newMeeting
+          createMeeting zUserTeam (ConnId "test-conn") newMeeting
 
       result `shouldSatisfy` isRight
 
     it "throws MeetingsFeatureDisabled on createMeeting for team user with meetings disabled" $ do
       result <-
         runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $
-          createMeeting zUserTeam newMeeting
+          createMeeting zUserTeam (ConnId "test-conn") newMeeting
 
       result `shouldBe` Left MeetingsFeatureDisabled
 
     it "returns Nothing on getMeeting for team user with meetings disabled" $ do
       result <-
         runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $
-          createMeeting zUserTeam newMeeting
+          createMeeting zUserTeam (ConnId "test-conn") newMeeting
 
       case result of
         Left err -> fail $ "Failed to create meeting: " <> show err
@@ -1288,21 +1288,21 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "throws MeetingsFeatureDisabled on updateMeeting for team user with meetings disabled" $ do
       result <-
         runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $
-          createMeeting zUserTeam newMeeting
+          createMeeting zUserTeam (ConnId "test-conn") newMeeting
 
       case result of
         Left err -> fail $ "Failed to create meeting: " <> show err
         Right meeting -> do
           result2 <-
             runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $
-              updateMeeting zUserTeam meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing)
+              updateMeeting zUserTeam (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing)
 
           result2 `shouldBe` Left MeetingsFeatureDisabled
 
     it "throws MeetingsFeatureDisabled on deleteMeeting for team user with meetings disabled" $ do
       result <-
         runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $
-          createMeeting zUserTeam newMeeting
+          createMeeting zUserTeam (ConnId "test-conn") newMeeting
 
       case result of
         Left err -> fail $ "Failed to create meeting: " <> show err
@@ -1323,7 +1323,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "throws MeetingsFeatureDisabled on addInvitedEmails for team user with meetings disabled" $ do
       result <-
         runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $
-          createMeeting zUserTeam newMeeting
+          createMeeting zUserTeam (ConnId "test-conn") newMeeting
 
       case result of
         Left err -> fail $ "Failed to create meeting: " <> show err
@@ -1337,7 +1337,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "throws MeetingsFeatureDisabled on removeInvitedEmails for team user with meetings disabled" $ do
       result <-
         runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $
-          createMeeting zUserTeam newMeeting
+          createMeeting zUserTeam (ConnId "test-conn") newMeeting
 
       case result of
         Left err -> fail $ "Failed to create meeting: " <> show err
@@ -1351,7 +1351,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "throws MeetingsFeatureDisabled on replaceInvitedEmails for team user with meetings disabled" $ do
       result <-
         runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $
-          createMeeting zUserTeam newMeeting
+          createMeeting zUserTeam (ConnId "test-conn") newMeeting
 
       case result of
         Left err -> fail $ "Failed to create meeting: " <> show err
@@ -1386,7 +1386,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "emits a meeting.create event on successful create" $ do
       result <-
         runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do
-          meeting <- createMeeting zUser1 newMeeting
+          meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
           pushes <- get @[Push]
           pure (meeting, pushes)
 
@@ -1401,9 +1401,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "emits a meeting.update event on successful update" $ do
       result <-
         runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do
-          meeting <- createMeeting zUser1 newMeeting
+          meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
           put @[Push] []
-          _ <- updateMeeting zUser1 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing)
+          _ <- updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing)
           get @[Push]
 
       case result of
@@ -1416,7 +1416,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "emits a meeting.delete event on successful delete" $ do
       result <-
         runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do
-          meeting <- createMeeting zUser1 newMeeting
+          meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
           put @[Push] []
           _ <- deleteMeeting zUser1 (ConnId "test-conn") meeting.meeting.id
           get @[Push]
@@ -1431,9 +1431,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "does not emit an event when updateMeeting fails (non-creator)" $ do
       result <-
         runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do
-          meeting <- createMeeting zUser1 newMeeting
+          meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
           put @[Push] []
-          _ <- updateMeeting zUser2 meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Hijack")) Nothing)
+          _ <- updateMeeting zUser2 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Hijack")) Nothing)
           get @[Push]
 
       case result of
@@ -1443,7 +1443,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     it "does not emit an event when deleteMeeting fails (non-creator)" $ do
       result <-
         runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do
-          meeting <- createMeeting zUser1 newMeeting
+          meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
           put @[Push] []
           _ <- deleteMeeting zUser2 (ConnId "test-conn") meeting.meeting.id
           get @[Push]
@@ -1452,7 +1452,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
         Left err -> fail $ "Error: " <> show err
         Right pushes -> extractMeetingEvents pushes `shouldBe` []
 
-    it "does not deliver lifecycle events to the meeting initiator" $ do
+    it "delivers lifecycle events to all members and propagates conn" $ do
       let members =
             [ newMember uid1,
               newMember uid2
@@ -1461,15 +1461,20 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
           convId = Id $ read "00000000-0000-0000-0000-000000000071"
           qMeetingId = Qualified meetingId (Domain "wire.com")
           qConvId = Qualified convId (Domain "wire.com")
+          originConn = ConnId "origin"
       result <-
         runTestStack now gen Map.empty def $ do
-          _ <- notifyMeetingEvent zUser1 Nothing members qConvId Nothing MeetingEvent.Create qMeetingId
+          _ <- notifyMeetingEvent zUser1 (Just originConn) members qConvId Nothing MeetingEvent.Create qMeetingId
           get @[Push]
       case result of
         Left err -> fail $ "Error: " <> show err
         Right pushes -> do
-          let recipientIds = map (.recipientUserId) (concatMap (.recipients) pushes)
-          recipientIds `shouldBe` [uid2]
+          let push = head pushes
+          -- Order is deterministic: 'members' is a literal list, 'map
+          -- localMemberToRecipient' preserves order, and mkMeetingEventPush
+          -- no longer filters or reorders recipients.
+          map (.recipientUserId) push.recipients `shouldBe` [uid1, uid2]
+          push.conn `shouldBe` Just originConn
 
 -- | Synchronize with 'Wire.MeetingsSubsystem.Interpreter.startTimeTolerance'
 expectedStartTimeTolerance :: NominalDiffTime
diff --git a/services/galley/src/Galley/API/Meetings.hs b/services/galley/src/Galley/API/Meetings.hs
index 20a1101433a..a43955905d2 100644
--- a/services/galley/src/Galley/API/Meetings.hs
+++ b/services/galley/src/Galley/API/Meetings.hs
@@ -40,22 +40,24 @@ import Wire.MeetingsSubsystem qualified as Meetings
 createMeeting ::
   (Member Meetings.MeetingsSubsystem r) =>
   Local UserId ->
+  ConnId ->
   NewMeeting ->
   Sem r MeetingWithConversation
-createMeeting lUser newMeeting = Meetings.createMeeting lUser newMeeting
+createMeeting lUser connId newMeeting = Meetings.createMeeting lUser connId newMeeting
 
 updateMeeting ::
   ( Member Meetings.MeetingsSubsystem r,
     Member (ErrorS 'MeetingNotFound) r
   ) =>
   Local UserId ->
+  ConnId ->
   Domain ->
   MeetingId ->
   UpdateMeeting ->
   Sem r MeetingWithConversation
-updateMeeting zUser domain meetingId update = do
+updateMeeting zUser connId domain meetingId update = do
   let qMeetingId = Qualified meetingId domain
-  maybeMeeting <- Meetings.updateMeeting zUser qMeetingId update
+  maybeMeeting <- Meetings.updateMeeting zUser connId qMeetingId update
   case maybeMeeting of
     Nothing -> throwS @'MeetingNotFound
     Just meeting -> pure meeting

From efd88aff3fe0b192d092029d60704179281650a8 Mon Sep 17 00:00:00 2001
From: Matthias Fischmann 
Date: Tue, 11 Aug 2026 10:45:54 +0200
Subject: [PATCH 077/113] [WPB-18127] Move email template completeness tests
 from brig to wire-subsystems. (#5429)

---
 ...eteness-tests-from-brig-to-wire-subsystems |   1 +
 .../src/Wire/EmailSubsystem/Template.hs       |  97 ++++++++++
 .../Wire/EmailSubsystem/TemplateFixtures.hs   |  79 ++++++++
 .../unit/Wire/EmailSubsystem/TemplateSpec.hs  | 176 +++++++++---------
 .../SAMLEmailSubsystem/InterpreterSpec.hs     |  41 +---
 libs/wire-subsystems/wire-subsystems.cabal    |   2 +
 services/brig/brig.cabal                      |   1 -
 services/brig/src/Brig/User/Template.hs       | 101 ++--------
 services/brig/test/integration/Run.hs         |   5 +-
 9 files changed, 290 insertions(+), 213 deletions(-)
 create mode 100644 changelog.d/5-internal/WPB-18127-move-email-template-completeness-tests-from-brig-to-wire-subsystems
 create mode 100644 libs/wire-subsystems/test/unit/Wire/EmailSubsystem/TemplateFixtures.hs
 rename services/brig/test/integration/API/Template.hs => libs/wire-subsystems/test/unit/Wire/EmailSubsystem/TemplateSpec.hs (57%)

diff --git a/changelog.d/5-internal/WPB-18127-move-email-template-completeness-tests-from-brig-to-wire-subsystems b/changelog.d/5-internal/WPB-18127-move-email-template-completeness-tests-from-brig-to-wire-subsystems
new file mode 100644
index 00000000000..8e334bf6bbf
--- /dev/null
+++ b/changelog.d/5-internal/WPB-18127-move-email-template-completeness-tests-from-brig-to-wire-subsystems
@@ -0,0 +1 @@
+Move email template completeness tests from brig to wire-subsystems.
diff --git a/libs/wire-subsystems/src/Wire/EmailSubsystem/Template.hs b/libs/wire-subsystems/src/Wire/EmailSubsystem/Template.hs
index 2a2550cfd35..11d36455eb2 100644
--- a/libs/wire-subsystems/src/Wire/EmailSubsystem/Template.hs
+++ b/libs/wire-subsystems/src/Wire/EmailSubsystem/Template.hs
@@ -39,6 +39,7 @@ import System.Logger (field, msg, val)
 import Wire.API.Locale
 import Wire.API.User.EmailAddress (EmailAddress)
 import Wire.EmailSubsystem.Templates.Team
+import Wire.EmailSubsystem.Templates.User
 
 -- | Lookup a localised item from a 'Localised' structure.
 forLocale ::
@@ -252,3 +253,99 @@ loadTeamTemplates tOptions templatesDir defLocale sender = readLocalesDir defLoc
     tExistingUrl = template tOptions.tExistingUserInvitationUrl
     readTemplate' = readTemplateWithDefault templatesDir defLocale "team"
     readText' = readTextWithDefault templatesDir defLocale "team"
+
+-- | URL templates needed to render the user email templates. These are plain
+-- 'Text' 'Data.Text.Template.template' strings, mirroring the corresponding
+-- Brig configuration fields; 'loadUserTemplates' turns them into 'Template's.
+data UserTemplateOpts = UserTemplateOpts
+  { -- | Activation URL template
+    activationUrl :: !Text,
+    -- | Team activation URL template
+    teamActivationUrl :: !Text,
+    -- | Password reset URL template
+    passwordResetUrl :: !Text,
+    -- | Deletion URL template
+    deletionUrl :: !Text
+  }
+  deriving stock (Show, Generic)
+
+loadUserTemplates :: UserTemplateOpts -> FilePath -> Locale -> EmailAddress -> IO (Localised UserTemplates)
+loadUserTemplates opts templatesDir defLocale sender = readLocalesDir defLocale templatesDir "user" $ \fp ->
+  UserTemplates
+    <$> ( VerificationEmailTemplate activationUrl
+            <$> readTemplate' fp "email/verification-subject.txt"
+            <*> readTemplate' fp "email/verification.txt"
+            <*> readTemplate' fp "email/verification.html"
+            <*> pure sender
+            <*> readText' fp "email/sender.txt"
+        )
+    <*> ( ActivationEmailTemplate activationUrl
+            <$> readTemplate' fp "email/activation-subject.txt"
+            <*> readTemplate' fp "email/activation.txt"
+            <*> readTemplate' fp "email/activation.html"
+            <*> pure sender
+            <*> readText' fp "email/sender.txt"
+        )
+    <*> ( ActivationEmailTemplate activationUrl
+            <$> readTemplate' fp "email/update-subject.txt"
+            <*> readTemplate' fp "email/update.txt"
+            <*> readTemplate' fp "email/update.html"
+            <*> pure sender
+            <*> readText' fp "email/sender.txt"
+        )
+    <*> ( TeamActivationEmailTemplate teamActivationUrl
+            <$> readTemplate' fp "email/team-activation-subject.txt"
+            <*> readTemplate' fp "email/team-activation.txt"
+            <*> readTemplate' fp "email/team-activation.html"
+            <*> pure sender
+            <*> readText' fp "email/sender.txt"
+        )
+    <*> ( PasswordResetEmailTemplate passwordResetUrl
+            <$> readTemplate' fp "email/password-reset-subject.txt"
+            <*> readTemplate' fp "email/password-reset.txt"
+            <*> readTemplate' fp "email/password-reset.html"
+            <*> pure sender
+            <*> readText' fp "email/sender.txt"
+        )
+    <*> ( DeletionEmailTemplate deletionUrl
+            <$> readTemplate' fp "email/deletion-subject.txt"
+            <*> readTemplate' fp "email/deletion.txt"
+            <*> readTemplate' fp "email/deletion.html"
+            <*> pure sender
+            <*> readText' fp "email/sender.txt"
+        )
+    <*> ( NewClientEmailTemplate
+            <$> readTemplate' fp "email/new-client-subject.txt"
+            <*> readTemplate' fp "email/new-client.txt"
+            <*> readTemplate' fp "email/new-client.html"
+            <*> pure sender
+            <*> readText' fp "email/sender.txt"
+        )
+    <*> ( SecondFactorVerificationEmailTemplate
+            <$> readTemplate' fp "email/verification-login-subject.txt"
+            <*> readTemplate' fp "email/verification-login.txt"
+            <*> readTemplate' fp "email/verification-login.html"
+            <*> pure sender
+            <*> readText' fp "email/sender.txt"
+        )
+    <*> ( SecondFactorVerificationEmailTemplate
+            <$> readTemplate' fp "email/verification-scim-token-subject.txt"
+            <*> readTemplate' fp "email/verification-scim-token.txt"
+            <*> readTemplate' fp "email/verification-scim-token.html"
+            <*> pure sender
+            <*> readText' fp "email/sender.txt"
+        )
+    <*> ( SecondFactorVerificationEmailTemplate
+            <$> readTemplate' fp "email/verification-delete-team-subject.txt"
+            <*> readTemplate' fp "email/verification-delete-team.txt"
+            <*> readTemplate' fp "email/verification-delete-team.html"
+            <*> pure sender
+            <*> readText' fp "email/sender.txt"
+        )
+  where
+    activationUrl = template opts.activationUrl
+    teamActivationUrl = template opts.teamActivationUrl
+    passwordResetUrl = template opts.passwordResetUrl
+    deletionUrl = template opts.deletionUrl
+    readTemplate' = readTemplateWithDefault templatesDir defLocale "user"
+    readText' = readTextWithDefault templatesDir defLocale "user"
diff --git a/libs/wire-subsystems/test/unit/Wire/EmailSubsystem/TemplateFixtures.hs b/libs/wire-subsystems/test/unit/Wire/EmailSubsystem/TemplateFixtures.hs
new file mode 100644
index 00000000000..b6d744ef82c
--- /dev/null
+++ b/libs/wire-subsystems/test/unit/Wire/EmailSubsystem/TemplateFixtures.hs
@@ -0,0 +1,79 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+-- | Shared fixtures for tests that render email templates from the on-disk
+-- templates shipped with this package. The URL templates and branding here
+-- mirror the shape of the corresponding Brig configuration.
+module Wire.EmailSubsystem.TemplateFixtures where
+
+import Data.Map qualified as Map
+import Imports
+import Text.Email.Parser (unsafeEmailAddress)
+import Wire.API.Locale
+import Wire.API.User.EmailAddress (EmailAddress)
+import Wire.EmailSubsystem.Template
+import Wire.EmailSubsystem.Templates.Team
+import Wire.EmailSubsystem.Templates.User
+
+teamOpts :: TeamOpts
+teamOpts =
+  TeamOpts
+    { tInvitationUrl = "https://example.com/join/?team-code=${code}",
+      tExistingUserInvitationUrl = "https://example.com/accept-invitation/?team-code=${code}",
+      tActivationUrl = "https://example.com/verify/?key=${key}&code=${code}",
+      tCreatorWelcomeUrl = "https://example.com/creator-welcome-website",
+      tMemberWelcomeUrl = "https://example.com/member-welcome-website"
+    }
+
+userTemplateOpts :: UserTemplateOpts
+userTemplateOpts =
+  UserTemplateOpts
+    { activationUrl = "https://example.com/verify/?key=${key}&code=${code}",
+      teamActivationUrl = teamOpts.tActivationUrl,
+      passwordResetUrl = "https://example.com/reset/?key=${key}&code=${code}",
+      deletionUrl = "https://example.com/d/?key=${key}&code=${code}"
+    }
+
+defLocale :: Locale
+defLocale = Locale ((fromJust . parseLanguage) "en") Nothing
+
+emailSender :: EmailAddress
+emailSender = unsafeEmailAddress "wire" "example.com"
+
+branding :: Map Text Text
+branding =
+  Map.fromList
+    [ ("brand", "Wire Test"),
+      ("brand_url", "https://wire.example.com"),
+      ("brand_label_url", "wire.example.com"),
+      ("brand_logo", "https://wire.example.com/p/img/email/logo-email-black.png"),
+      ("brand_service", "Wire Service Provider"),
+      ("copyright", "© WIRE SWISS GmbH"),
+      ("misuse", "misuse@wire.example.com"),
+      ("legal", "https://wire.example.com/legal/"),
+      ("forgot", "https://wire.example.com/forgot/"),
+      ("support", "https://support.wire.com/")
+    ]
+
+-- | Load the on-disk team templates. Relies on the test suite running with the
+-- package directory as its working directory (as @cabal test@ does).
+loadTestTeamTemplates :: IO (Localised TeamTemplates)
+loadTestTeamTemplates = loadTeamTemplates teamOpts "templates" defLocale emailSender
+
+-- | Load the on-disk user templates. See 'loadTestTeamTemplates'.
+loadTestUserTemplates :: IO (Localised UserTemplates)
+loadTestUserTemplates = loadUserTemplates userTemplateOpts "templates" defLocale emailSender
diff --git a/services/brig/test/integration/API/Template.hs b/libs/wire-subsystems/test/unit/Wire/EmailSubsystem/TemplateSpec.hs
similarity index 57%
rename from services/brig/test/integration/API/Template.hs
rename to libs/wire-subsystems/test/unit/Wire/EmailSubsystem/TemplateSpec.hs
index 4c697b88ed5..d19313e9d69 100644
--- a/services/brig/test/integration/API/Template.hs
+++ b/libs/wire-subsystems/test/unit/Wire/EmailSubsystem/TemplateSpec.hs
@@ -1,10 +1,22 @@
-module API.Template (tests) where
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Wire.EmailSubsystem.TemplateSpec (spec) where
 
-import Bilge
-import Brig.Options
-import Brig.Team.Template (loadTeamTemplatesWithBrigOpts)
-import Brig.Template
-import Brig.User.Template (loadUserTemplates)
 import Data.Code
 import Data.Id
 import Data.Json.Util
@@ -18,9 +30,7 @@ import Imports
 import Network.Mail.Mime
 import Polysemy
 import Polysemy.Output
-import Test.Tasty
-import Test.Tasty.HUnit
-import Util
+import Test.Hspec
 import Wire.API.Locale
 import Wire.API.User (InvitationCode (InvitationCode, fromInvitationCode))
 import Wire.API.User.Activation
@@ -30,57 +40,43 @@ import Wire.API.User.Password
 import Wire.API.User.Profile
 import Wire.EmailSubsystem.Interpreter
 import Wire.EmailSubsystem.Template
+import Wire.EmailSubsystem.TemplateFixtures
 import Wire.EmailSubsystem.Templates.Team
 import Wire.EmailSubsystem.Templates.User
 
--- FUTUREWORK: This does not have to be an integration test. It could be
--- covered by unit tests in wire-subsystems. Then, the helper functions can be
--- privatized.
-tests :: Opts -> Manager -> IO TestTree
-tests opts m = do
-  team <- liftIO $ loadTeamTemplatesWithBrigOpts opts
-  user <- liftIO $ loadUserTemplates opts
-  let teamTemplates = Map.assocs $ uncurry Map.insert team.locDefault team.locOther
-      userTemplates = Map.assocs $ uncurry Map.insert user.locDefault user.locOther
-      b = genTemplateBrandingMap opts.emailSMS.general.templateBranding
-  pure $
-    testGroup
-      "email templates"
-      [ testGroup
-          "team"
-          $ fmap
-            ( \(loc, ts) ->
-                testGroup
-                  (show loc)
-                  [ test m "team invitation" $ testTeamInvitationEmail b ts,
-                    test m "team invitation existing user" $ testTeamInvitationEmailExistingUser b ts,
-                    test m "member welcome" $ testMemberWelcomeEmail b ts,
-                    test m "new team owner welcome" $ testNewTeamOwnerWelcomeEmail b ts
-                  ]
-            )
-            teamTemplates,
-        testGroup "user" $
-          fmap
-            ( \(loc, ts) ->
-                testGroup
-                  (show loc)
-                  [ test m "password reset email" $ testPasswordResetEmail b ts,
-                    test m "verification email" $ testVerificationEmail b ts,
-                    test m "team deletion verification email" $ testTeamDeletionVerificationEmail b ts,
-                    test m "scim token verification email" $ testScimTokenVerificationEmail b ts,
-                    test m "login verification email" $ testLoginVerificationEmail b ts,
-                    test m "new client email" $ testNewClientEmail b loc ts,
-                    test m "account deletion email" $ testAccountDeletionEmail b ts,
-                    test m "activation email" $ testActivationEmail b ts,
-                    test m "activation email update" $ testActivationEmailUpdate b ts,
-                    test m "team activation email" $ testTeamActivationEmail b ts
-                  ]
-            )
-            userTemplates
-      ]
-
-testTeamInvitationEmailExistingUser :: (HasCallStack) => Map Text Text -> TeamTemplates -> Http ()
-testTeamInvitationEmailExistingUser branding templates = do
+-- | Insert the default locale into the map of other locales, giving the full
+-- set of locales that ship templates. Each is exercised below.
+byLocale :: Localised a -> [(Locale, a)]
+byLocale l = Map.assocs $ uncurry Map.insert l.locDefault l.locOther
+
+spec :: Spec
+spec = do
+  teamTemplates <- runIO loadTestTeamTemplates
+  userTemplates <- runIO loadTestUserTemplates
+  describe "email templates" $ do
+    describe "team" $
+      for_ (byLocale teamTemplates) $ \(loc, ts) ->
+        describe (show loc) $ do
+          it "team invitation" $ testTeamInvitationEmail ts
+          it "team invitation existing user" $ testTeamInvitationEmailExistingUser ts
+          it "member welcome" $ testMemberWelcomeEmail ts
+          it "new team owner welcome" $ testNewTeamOwnerWelcomeEmail ts
+    describe "user" $
+      for_ (byLocale userTemplates) $ \(loc, ts) ->
+        describe (show loc) $ do
+          it "password reset email" $ testPasswordResetEmail ts
+          it "verification email" $ testVerificationEmail ts
+          it "team deletion verification email" $ testTeamDeletionVerificationEmail ts
+          it "scim token verification email" $ testScimTokenVerificationEmail ts
+          it "login verification email" $ testLoginVerificationEmail ts
+          it "new client email" $ testNewClientEmail loc ts
+          it "account deletion email" $ testAccountDeletionEmail ts
+          it "activation email" $ testActivationEmail ts
+          it "activation email update" $ testActivationEmailUpdate ts
+          it "team activation email" $ testTeamActivationEmail ts
+
+testTeamInvitationEmailExistingUser :: (HasCallStack) => TeamTemplates -> Expectation
+testTeamInvitationEmailExistingUser templates = do
   let tpl = templates.existingUserInvitationEmail
       (errs, (mail, url)) = run $ runOutputList @Text $ renderInvitationEmail input tpl branding
       input =
@@ -90,12 +86,12 @@ testTeamInvitationEmailExistingUser branding templates = do
             invInvCode = InvitationCode {fromInvitationCode = fromRight undefined (validate "ZoMX0xs=")},
             invInviter = fromJust $ emailAddressText "inviter@example.com"
           }
-  liftIO $ mail.mailFrom.addressEmail @?= (fromEmail tpl.invitationEmailSender)
-  liftIO $ url @?= "https://example.com/accept-invitation/?team-code=ZoMX0xs="
+  mail.mailFrom.addressEmail `shouldBe` fromEmail tpl.invitationEmailSender
+  url `shouldBe` "https://example.com/accept-invitation/?team-code=ZoMX0xs="
   assertNoErrors errs
 
-testTeamInvitationEmail :: (HasCallStack) => Map Text Text -> TeamTemplates -> Http ()
-testTeamInvitationEmail branding templates = do
+testTeamInvitationEmail :: (HasCallStack) => TeamTemplates -> Expectation
+testTeamInvitationEmail templates = do
   let tpl = templates.invitationEmail
       (errs, (mail, url)) = run $ runOutputList @Text $ renderInvitationEmail input tpl branding
       input =
@@ -105,12 +101,12 @@ testTeamInvitationEmail branding templates = do
             invInvCode = InvitationCode {fromInvitationCode = fromRight undefined (validate "ZoMX0xs=")},
             invInviter = fromJust $ emailAddressText "inviter@example.com"
           }
-  liftIO $ mail.mailFrom.addressEmail @?= (fromEmail tpl.invitationEmailSender)
-  liftIO $ url @?= "https://example.com/join/?team-code=ZoMX0xs="
+  mail.mailFrom.addressEmail `shouldBe` fromEmail tpl.invitationEmailSender
+  url `shouldBe` "https://example.com/join/?team-code=ZoMX0xs="
   assertNoErrors errs
 
-testMemberWelcomeEmail :: (HasCallStack) => Map Text Text -> TeamTemplates -> Http ()
-testMemberWelcomeEmail branding templates = do
+testMemberWelcomeEmail :: (HasCallStack) => TeamTemplates -> Expectation
+testMemberWelcomeEmail templates = do
   let tpl = templates.memberWelcomeEmail
       to = fromJust $ emailAddressText "test@example.com"
       tid = Id (fromJust $ UUID.fromString "123e4567-e89b-12d3-a456-426614174000")
@@ -118,8 +114,8 @@ testMemberWelcomeEmail branding templates = do
       (errs, _) = run $ runOutputList @Text $ renderMemberWelcomeMail to tid tname tpl branding
   assertNoErrors errs
 
-testNewTeamOwnerWelcomeEmail :: (HasCallStack) => Map Text Text -> TeamTemplates -> Http ()
-testNewTeamOwnerWelcomeEmail branding templates = do
+testNewTeamOwnerWelcomeEmail :: (HasCallStack) => TeamTemplates -> Expectation
+testNewTeamOwnerWelcomeEmail templates = do
   let tpl = templates.newTeamOwnerWelcomeEmail
       to = fromJust $ emailAddressText "test@example.com"
       tid = Id (fromJust $ UUID.fromString "123e4567-e89b-12d3-a456-426614174000")
@@ -128,8 +124,8 @@ testNewTeamOwnerWelcomeEmail branding templates = do
       (errs, _) = run $ runOutputList @Text $ renderNewTeamOwnerWelcomeEmail to tid tname name tpl branding
   assertNoErrors errs
 
-testPasswordResetEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http ()
-testPasswordResetEmail branding templates = do
+testPasswordResetEmail :: (HasCallStack) => UserTemplates -> Expectation
+testPasswordResetEmail templates = do
   let tpl = templates.passwordResetEmail
       to = fromJust $ emailAddressText "test@example.com"
       key = mkPasswordResetKey (Id UUID.nil)
@@ -137,8 +133,8 @@ testPasswordResetEmail branding templates = do
       (errs, _) = run $ runOutputList @Text $ renderPwResetMail to key code tpl branding
   assertNoErrors errs
 
-testVerificationEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http ()
-testVerificationEmail branding templates = do
+testVerificationEmail :: (HasCallStack) => UserTemplates -> Expectation
+testVerificationEmail templates = do
   let tpl = templates.verificationEmail
       to = fromJust $ emailAddressText "test@example.com"
       key = ActivationKey . Ascii.unsafeFromText $ "key"
@@ -146,32 +142,32 @@ testVerificationEmail branding templates = do
       (errs, _) = run $ runOutputList @Text $ renderVerificationMail to key code tpl branding
   assertNoErrors errs
 
-testTeamDeletionVerificationEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http ()
-testTeamDeletionVerificationEmail branding templates = do
+testTeamDeletionVerificationEmail :: (HasCallStack) => UserTemplates -> Expectation
+testTeamDeletionVerificationEmail templates = do
   let tpl = templates.verificationTeamDeletionEmail
       to = fromJust $ emailAddressText "test@example.com"
       code = Value . unsafeRange . Ascii.unsafeFromText $ "code"
       (errs, _) = run $ runOutputList @Text $ renderSecondFactorVerificationEmail to code tpl branding
   assertNoErrors errs
 
-testScimTokenVerificationEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http ()
-testScimTokenVerificationEmail branding templates = do
+testScimTokenVerificationEmail :: (HasCallStack) => UserTemplates -> Expectation
+testScimTokenVerificationEmail templates = do
   let tpl = templates.verificationScimTokenEmail
       to = fromJust $ emailAddressText "test@example.com"
       code = Value . unsafeRange . Ascii.unsafeFromText $ "code"
       (errs, _) = run $ runOutputList @Text $ renderSecondFactorVerificationEmail to code tpl branding
   assertNoErrors errs
 
-testLoginVerificationEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http ()
-testLoginVerificationEmail branding templates = do
+testLoginVerificationEmail :: (HasCallStack) => UserTemplates -> Expectation
+testLoginVerificationEmail templates = do
   let tpl = templates.verificationLoginEmail
       to = fromJust $ emailAddressText "test@example.com"
       code = Value . unsafeRange . Ascii.unsafeFromText $ "code"
       (errs, _) = run $ runOutputList @Text $ renderSecondFactorVerificationEmail to code tpl branding
   assertNoErrors errs
 
-testActivationEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http ()
-testActivationEmail branding templates = do
+testActivationEmail :: (HasCallStack) => UserTemplates -> Expectation
+testActivationEmail templates = do
   let tpl = templates.activationEmail
       to = fromJust $ emailAddressText "test@example.com"
       name = Name "name"
@@ -180,8 +176,8 @@ testActivationEmail branding templates = do
       (errs, _) = run $ runOutputList @Text $ renderActivationMail to name key code tpl branding
   assertNoErrors errs
 
-testActivationEmailUpdate :: (HasCallStack) => Map Text Text -> UserTemplates -> Http ()
-testActivationEmailUpdate branding templates = do
+testActivationEmailUpdate :: (HasCallStack) => UserTemplates -> Expectation
+testActivationEmailUpdate templates = do
   let tpl = templates.activationEmailUpdate
       to = fromJust $ emailAddressText "test@example.com"
       name = Name "name"
@@ -190,8 +186,8 @@ testActivationEmailUpdate branding templates = do
       (errs, _) = run $ runOutputList @Text $ renderActivationMail to name key code tpl branding
   assertNoErrors errs
 
-testTeamActivationEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http ()
-testTeamActivationEmail branding templates = do
+testTeamActivationEmail :: (HasCallStack) => UserTemplates -> Expectation
+testTeamActivationEmail templates = do
   let tpl = templates.teamActivationEmail
       to = fromJust $ emailAddressText "test@example.com"
       name = Name "name"
@@ -201,8 +197,8 @@ testTeamActivationEmail branding templates = do
       (errs, _) = run $ runOutputList @Text $ renderTeamActivationMail to name teamName key code tpl branding
   assertNoErrors errs
 
-testNewClientEmail :: (HasCallStack) => Map Text Text -> Locale -> UserTemplates -> Http ()
-testNewClientEmail branding loc templates = do
+testNewClientEmail :: (HasCallStack) => Locale -> UserTemplates -> Expectation
+testNewClientEmail loc templates = do
   let tpl = templates.newClientEmail
       to = fromJust $ emailAddressText "test@example.com"
       name = Name "name"
@@ -222,8 +218,8 @@ testNewClientEmail branding loc templates = do
       (errs, _) = run $ runOutputList @Text $ renderNewClientEmail to name loc client tpl branding
   assertNoErrors errs
 
-testAccountDeletionEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http ()
-testAccountDeletionEmail branding templates = do
+testAccountDeletionEmail :: (HasCallStack) => UserTemplates -> Expectation
+testAccountDeletionEmail templates = do
   let tpl = templates.deletionEmail
       to = fromJust $ emailAddressText "test@example.com"
       name = Name "name"
@@ -232,7 +228,7 @@ testAccountDeletionEmail branding templates = do
       (errs, _) = run $ runOutputList @Text $ renderDeletionEmail to name key code tpl branding
   assertNoErrors errs
 
-assertNoErrors :: [Text] -> Http ()
+assertNoErrors :: (HasCallStack) => [Text] -> Expectation
 assertNoErrors errs =
-  liftIO $
-    assertBool ("The following variables were not replaced: " <> show (nub errs)) (null errs)
+  unless (null errs) $
+    expectationFailure ("The following variables were not replaced: " <> show (nub errs))
diff --git a/libs/wire-subsystems/test/unit/Wire/SAMLEmailSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/SAMLEmailSubsystem/InterpreterSpec.hs
index fba4f321bd2..e44e517bbfc 100644
--- a/libs/wire-subsystems/test/unit/Wire/SAMLEmailSubsystem/InterpreterSpec.hs
+++ b/libs/wire-subsystems/test/unit/Wire/SAMLEmailSubsystem/InterpreterSpec.hs
@@ -39,6 +39,7 @@ import Wire.EmailSending
 import Wire.EmailSubsystem qualified as Email
 import Wire.EmailSubsystem.Interpreter
 import Wire.EmailSubsystem.Template
+import Wire.EmailSubsystem.TemplateFixtures
 import Wire.EmailSubsystem.Templates.Team
 import Wire.GalleyAPIAccess
 import Wire.MockInterpreters
@@ -76,32 +77,9 @@ spec = do
         flip zip ((replicate 5 enTextParts) ++ (replicate 2 deTextParts)) $
           parseLocalUnsafe <$> ["en", "en-EN", "en-GB", "es", "es-ES", "de", "de_DE"]
       parseLocalUnsafe = fromMaybe (error "Unknown locale") . parseLocale
-      teamOpts =
-        TeamOpts
-          { tInvitationUrl = "https://example.com/join/?team-code=${code}",
-            tExistingUserInvitationUrl = "https://example.com/accept-invitation/?team-code=${code}",
-            tActivationUrl = "https://example.com/verify/?key=${key}&code=${code}",
-            tCreatorWelcomeUrl = "https://example.com/creator-welcome-website",
-            tMemberWelcomeUrl = "https://example.com/member-welcome-website"
-          }
-      defLocale = Locale ((fromJust . parseLanguage) "en") Nothing
-      emailSender = unsafeEmailAddress "wire" "example.com"
-      branding =
-        Map.fromList
-          [ ("brand", "Wire Test"),
-            ("brand_url", "https://wire.example.com"),
-            ("brand_label_url", "wire.example.com"),
-            ("brand_logo", "https://wire.example.com/p/img/email/logo-email-black.png"),
-            ("brand_service", "Wire Service Provider"),
-            ("copyright", "© WIRE SWISS GmbH"),
-            ("misuse", "misuse@wire.example.com"),
-            ("legal", "https://wire.example.com/legal/"),
-            ("forgot", "https://wire.example.com/forgot/"),
-            ("support", "https://support.wire.com/")
-          ]
 
   -- Run duplicated IO tasks here to save some time
-  teamTemplates :: Localised TeamTemplates <- runIO $ loadTeamTemplates teamOpts "templates" defLocale emailSender
+  teamTemplates :: Localised TeamTemplates <- runIO loadTestTeamTemplates
   newCerts <- runIO $ X509.readCertificates "test/resources/saml/certs.store"
 
   describe "SendSAMLIdPChanged" $ do
@@ -118,7 +96,7 @@ spec = do
               storedUser' = patchStoredUser storedUser teamId userLocale uid
               notif = IdPCreated (Just uid) idp'
 
-          (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates branding $ do
+          (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates $ do
             sendSAMLIdPChanged notif
 
           assertNoWarnLogs logs
@@ -134,7 +112,7 @@ spec = do
           let idp' = patchIdP idp teamId
               storedUser' = patchStoredUser storedUser teamId userLocale uid
               notif = IdPDeleted uid idp'
-          (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates branding $ do
+          (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates $ do
             sendSAMLIdPChanged notif
 
           assertNoWarnLogs logs
@@ -167,7 +145,7 @@ spec = do
                     )
               storedUser' = patchStoredUser storedUser teamId userLocale uid
               notif = IdPUpdated uid idpOld' idpNew'
-          (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates branding $ do
+          (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates $ do
             sendSAMLIdPChanged notif
 
           assertNoWarnLogs logs
@@ -186,7 +164,7 @@ spec = do
               teamMember :: TeamMember = mkTeamMember uid (rolePermissions role) Nothing UserLegalHoldDisabled
               teamMap :: Map TeamId [TeamMember] = Map.singleton teamId [teamMember]
 
-          (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates branding $ do
+          (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates $ do
             sendSAMLIdPChanged notif
 
           assertNoWarnLogs logs
@@ -201,7 +179,7 @@ spec = do
               teamMember :: TeamMember = mkTeamMember uid (rolePermissions role) Nothing UserLegalHoldDisabled
               teamMap :: Map TeamId [TeamMember] = Map.singleton teamId [teamMember]
 
-          (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates branding $ do
+          (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates $ do
             sendSAMLIdPChanged notif
 
           assertNoWarnLogs logs
@@ -220,7 +198,7 @@ spec = do
                   )
                   users
 
-          (mails, logs, _res) <- runInterpreters (fst <$> users) teamMap teamTemplates branding $ do
+          (mails, logs, _res) <- runInterpreters (fst <$> users) teamMap teamTemplates $ do
             sendSAMLIdPChanged notif
 
           assertNoWarnLogs logs
@@ -339,7 +317,6 @@ runInterpreters ::
   [StoredUser] ->
   Map TeamId [TeamMember] ->
   Localised TeamTemplates ->
-  Map Text Text ->
   Sem
     '[ SAMLEmailSubsystem,
        TeamSubsystem,
@@ -357,7 +334,7 @@ runInterpreters ::
      ]
     a ->
   IO ([Mail], [(Level, LByteString)], a)
-runInterpreters users teamMap teamTemplates branding action = do
+runInterpreters users teamMap teamTemplates action = do
   lr <- newLogRecorder
   (mails, res) <-
     runM
diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal
index 93f43dfd5d7..be21d0b50c6 100644
--- a/libs/wire-subsystems/wire-subsystems.cabal
+++ b/libs/wire-subsystems/wire-subsystems.cabal
@@ -635,6 +635,8 @@ test-suite wire-subsystems-tests
     Wire.ConversationSubsystem.InterpreterSpec
     Wire.ConversationSubsystem.MessageSpec
     Wire.ConversationSubsystem.One2OneSpec
+    Wire.EmailSubsystem.TemplateFixtures
+    Wire.EmailSubsystem.TemplateSpec
     Wire.EnterpriseLoginSubsystem.InterpreterSpec
     Wire.FederationSubsystem.InternalsSpec
     Wire.HashPassword.InterpreterSpec
diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal
index 6a8dc1f408f..89b1d88f7a7 100644
--- a/services/brig/brig.cabal
+++ b/services/brig/brig.cabal
@@ -350,7 +350,6 @@ executable brig-integration
     API.Team
     API.Team.Util
     API.TeamUserSearch
-    API.Template
     API.User
     API.User.Account
     API.User.Auth
diff --git a/services/brig/src/Brig/User/Template.hs b/services/brig/src/Brig/User/Template.hs
index ea7193faadf..9f9636f510e 100644
--- a/services/brig/src/Brig/User/Template.hs
+++ b/services/brig/src/Brig/User/Template.hs
@@ -18,94 +18,23 @@
 module Brig.User.Template (loadUserTemplates) where
 
 import Brig.Options qualified as Opt
-import Data.Text.Template
 import Imports
-import Wire.EmailSubsystem.Template hiding (readTemplate, readText)
+import Wire.EmailSubsystem.Template hiding (loadUserTemplates)
+import Wire.EmailSubsystem.Template qualified as EmailTemplate
 import Wire.EmailSubsystem.Templates.User
 
 loadUserTemplates :: Opt.Opts -> IO (Localised UserTemplates)
-loadUserTemplates o = readLocalesDir defLocale templateDir "user" $ \fp ->
-  UserTemplates
-    <$> ( VerificationEmailTemplate activationUrl
-            <$> readTemplate fp "email/verification-subject.txt"
-            <*> readTemplate fp "email/verification.txt"
-            <*> readTemplate fp "email/verification.html"
-            <*> pure emailSender
-            <*> readText fp "email/sender.txt"
-        )
-    <*> ( ActivationEmailTemplate activationUrl
-            <$> readTemplate fp "email/activation-subject.txt"
-            <*> readTemplate fp "email/activation.txt"
-            <*> readTemplate fp "email/activation.html"
-            <*> pure emailSender
-            <*> readText fp "email/sender.txt"
-        )
-    <*> ( ActivationEmailTemplate activationUrl
-            <$> readTemplate fp "email/update-subject.txt"
-            <*> readTemplate fp "email/update.txt"
-            <*> readTemplate fp "email/update.html"
-            <*> pure emailSender
-            <*> readText fp "email/sender.txt"
-        )
-    <*> ( TeamActivationEmailTemplate teamActivationUrl
-            <$> readTemplate fp "email/team-activation-subject.txt"
-            <*> readTemplate fp "email/team-activation.txt"
-            <*> readTemplate fp "email/team-activation.html"
-            <*> pure emailSender
-            <*> readText fp "email/sender.txt"
-        )
-    <*> ( PasswordResetEmailTemplate passwordResetUrl
-            <$> readTemplate fp "email/password-reset-subject.txt"
-            <*> readTemplate fp "email/password-reset.txt"
-            <*> readTemplate fp "email/password-reset.html"
-            <*> pure emailSender
-            <*> readText fp "email/sender.txt"
-        )
-    <*> ( DeletionEmailTemplate deletionUserUrl
-            <$> readTemplate fp "email/deletion-subject.txt"
-            <*> readTemplate fp "email/deletion.txt"
-            <*> readTemplate fp "email/deletion.html"
-            <*> pure emailSender
-            <*> readText fp "email/sender.txt"
-        )
-    <*> ( NewClientEmailTemplate
-            <$> readTemplate fp "email/new-client-subject.txt"
-            <*> readTemplate fp "email/new-client.txt"
-            <*> readTemplate fp "email/new-client.html"
-            <*> pure emailSender
-            <*> readText fp "email/sender.txt"
-        )
-    <*> ( SecondFactorVerificationEmailTemplate
-            <$> readTemplate fp "email/verification-login-subject.txt"
-            <*> readTemplate fp "email/verification-login.txt"
-            <*> readTemplate fp "email/verification-login.html"
-            <*> pure emailSender
-            <*> readText fp "email/sender.txt"
-        )
-    <*> ( SecondFactorVerificationEmailTemplate
-            <$> readTemplate fp "email/verification-scim-token-subject.txt"
-            <*> readTemplate fp "email/verification-scim-token.txt"
-            <*> readTemplate fp "email/verification-scim-token.html"
-            <*> pure emailSender
-            <*> readText fp "email/sender.txt"
-        )
-    <*> ( SecondFactorVerificationEmailTemplate
-            <$> readTemplate fp "email/verification-delete-team-subject.txt"
-            <*> readTemplate fp "email/verification-delete-team.txt"
-            <*> readTemplate fp "email/verification-delete-team.html"
-            <*> pure emailSender
-            <*> readText fp "email/sender.txt"
-        )
+loadUserTemplates o =
+  EmailTemplate.loadUserTemplates
+    userTemplateOpts
+    o.emailSMS.general.templateDir
+    (Opt.defaultTemplateLocale o.settings)
+    o.emailSMS.general.emailSender
   where
-    gOptions = o.emailSMS.general
-    uOptions = o.emailSMS.user
-    tOptions = o.emailSMS.team
-    emailSender = gOptions.emailSender
-    activationUrl = template uOptions.activationUrl
-    teamActivationUrl = template tOptions.tActivationUrl
-    passwordResetUrl = template uOptions.passwordResetUrl
-    deletionUserUrl = template uOptions.deletionUrl
-    defLocale = Opt.defaultTemplateLocale o.settings
-    templateDir = gOptions.templateDir
-    readTemplate = readTemplateWithDefault templateDir defLocale "user"
-    readText = readTextWithDefault templateDir defLocale "user"
+    userTemplateOpts =
+      UserTemplateOpts
+        { activationUrl = o.emailSMS.user.activationUrl,
+          teamActivationUrl = o.emailSMS.team.tActivationUrl,
+          passwordResetUrl = o.emailSMS.user.passwordResetUrl,
+          deletionUrl = o.emailSMS.user.deletionUrl
+        }
diff --git a/services/brig/test/integration/Run.hs b/services/brig/test/integration/Run.hs
index e41bab0ff0f..205c48714df 100644
--- a/services/brig/test/integration/Run.hs
+++ b/services/brig/test/integration/Run.hs
@@ -29,7 +29,6 @@ import API.Search qualified as Search
 import API.Settings qualified as Settings
 import API.Team qualified as Team
 import API.TeamUserSearch qualified as TeamUserSearch
-import API.Template qualified
 import API.User qualified as User
 import Bilge hiding (header, host, port)
 import Bilge qualified
@@ -146,7 +145,6 @@ runTests iConf brigOpts otherArgs = do
   browseTeam <- TeamUserSearch.tests brigOpts mg g b
   federationEnd2End <- Federation.End2end.spec brigOpts mg b g ch c f brigTwo galleyTwo ch2 cannonTwo
   federationEndpoints <- API.Federation.tests mg brigOpts b fedBrigClient
-  emailTemplates <- API.Template.tests brigOpts mg
 
   let smtp = SMTP.tests mg lg
       oauthAPI = API.OAuth.tests mg db b n brigOpts
@@ -166,8 +164,7 @@ runTests iConf brigOpts otherArgs = do
         federationEndpoints,
         smtp,
         oauthAPI,
-        federationEnd2End,
-        emailTemplates
+        federationEnd2End
       ]
   where
     mkRequest (Endpoint h p) = Bilge.host (encodeUtf8 h) . Bilge.port p

From 082e43413f3acc9a553075f09714f4a72f6e02b6 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Tue, 11 Aug 2026 12:03:20 +0200
Subject: [PATCH 078/113] test(Cells): isolate testCellsDeletionEvent from
 shared cells queue (#5433)

---
 integration/test/Notifications.hs |  5 +++
 integration/test/Test/Cells.hs    | 64 +++++++++++++++----------------
 2 files changed, 37 insertions(+), 32 deletions(-)

diff --git a/integration/test/Notifications.hs b/integration/test/Notifications.hs
index 20795a4d51b..144b9902870 100644
--- a/integration/test/Notifications.hs
+++ b/integration/test/Notifications.hs
@@ -230,6 +230,11 @@ isConvDeleteNotif n =
   fieldEquals n "payload.0.type" "conversation.delete"
     ||~ fieldEquals n "payload.0.type" "conversation.system.delete"
 
+isNotifTeamConvDelete :: (HasCallStack, MakesValue conv, MakesValue a) => conv -> a -> App Bool
+isNotifTeamConvDelete conv n =
+  isNotifConv conv n
+    &&~ fieldEquals n "payload.0.type" "conversation.delete"
+
 isConvAdminlessReminderNotif :: (HasCallStack, MakesValue a) => a -> App Bool
 isConvAdminlessReminderNotif n = fieldEquals n "payload.0.type" "conversation.adminless-reminder"
 
diff --git a/integration/test/Test/Cells.hs b/integration/test/Test/Cells.hs
index 1f82013a9ad..06c46dd243e 100644
--- a/integration/test/Test/Cells.hs
+++ b/integration/test/Test/Cells.hs
@@ -42,7 +42,7 @@ testCellsEvent :: (HasCallStack) => App ()
 testCellsEvent = do
   (alice, tid, [bob, chaz, dean, eve]) <- createTeam OwnDomain 5
   conv <- postConversation alice defProteus {team = Just tid} >>= getJSON 201
-  q <- watchCellsEventsForTeam tid (convEvents conv)
+  cellsQueue <- watchCellsEventsForTeam tid (convEvents conv)
 
   bobId <- bob %. "qualified_id"
   chazId <- chaz %. "qualified_id"
@@ -55,7 +55,7 @@ testCellsEvent = do
   addMembers alice conv def {role = Just "wire_member", users = [chazId]} >>= assertSuccess
 
   do
-    event <- getMessage q %. "payload.0"
+    event <- getMessage cellsQueue %. "payload.0"
     event %. "type" `shouldMatch` "conversation.member-join"
     event %. "conversation" `shouldMatch` (conv %. "qualified_id" & objId)
     event %. "qualified_from" `shouldMatch` (alice %. "qualified_id")
@@ -66,7 +66,7 @@ testCellsEvent = do
   addMembers alice conv def {role = Just "wire_member", users = [deanId]} >>= assertSuccess
 
   do
-    event <- getMessage q %. "payload.0"
+    event <- getMessage cellsQueue %. "payload.0"
     event %. "type" `shouldMatch` "conversation.member-join"
     event %. "conversation" `shouldMatch` (conv %. "qualified_id" & objId)
     event %. "qualified_from" `shouldMatch` (alice %. "qualified_id")
@@ -76,33 +76,33 @@ testCellsEvent = do
   I.setCellsState alice conv "disabled" >>= assertSuccess
   addMembers alice conv def {role = Just "wire_member", users = [eveId]} >>= assertSuccess
 
-  assertNoMessage q
+  assertNoMessage cellsQueue
 
 testCellsCreationEvent :: (HasCallStack) => App ()
 testCellsCreationEvent = do
   (alice, tid, _) <- createTeam OwnDomain 1
-  q0 <- watchCellsEventsForTeam tid def
+  baseQueue <- watchCellsEventsForTeam tid def
   conv <- postConversation alice defProteus {team = Just tid, cells = True} >>= getJSON 201
 
-  let q = q0 {filter = isNotifConv conv} :: QueueConsumer
+  let cellsQueue = baseQueue {filter = isNotifConv conv} :: QueueConsumer
 
-  event <- getMessage q %. "payload.0"
+  event <- getMessage cellsQueue %. "payload.0"
   event %. "type" `shouldMatch` "conversation.create"
   event %. "qualified_conversation.id" `shouldMatch` (conv %. "qualified_id.id")
   event %. "qualified_from" `shouldMatch` (alice %. "qualified_id")
 
-  assertNoMessage q
+  assertNoMessage cellsQueue
 
 testCellsDeletionEvent :: (HasCallStack) => App ()
 testCellsDeletionEvent = do
   (alice, tid, _) <- createTeam OwnDomain 1
-  q0 <- watchCellsEventsForTeam tid def
+  baseQueue <- watchCellsEventsForTeam tid def
   conv <- postConversation alice defProteus {team = Just tid, cells = True} >>= getJSON 201
   void $ deleteTeamConversation tid conv alice >>= assertSuccess
 
-  let q = q0 {filter = isConvDeleteNotif} :: QueueConsumer
+  let cellsQueue = baseQueue {filter = isNotifTeamConvDelete conv} :: QueueConsumer
 
-  event <- getMessage q %. "payload.0"
+  event <- getMessage cellsQueue %. "payload.0"
   event %. "type" `shouldMatch` "conversation.delete"
   event %. "conversation" `shouldMatch` (conv %. "qualified_id.id")
   event %. "qualified_conversation" `shouldMatch` (conv %. "qualified_id")
@@ -110,22 +110,22 @@ testCellsDeletionEvent = do
   event %. "from" `shouldMatch` (alice %. "qualified_id.id")
   event %. "team" `shouldMatch` tid
 
-  assertNoMessage q
+  assertNoMessage cellsQueue
 
 testCellsCreationEventIsSentOnlyOnce :: (HasCallStack) => App ()
 testCellsCreationEventIsSentOnlyOnce = do
   (alice, tid, members) <- createTeam OwnDomain 2
-  q0 <- watchCellsEventsForTeam tid def
+  baseQueue <- watchCellsEventsForTeam tid def
   conv <- postConversation alice defProteus {team = Just tid, cells = True, qualifiedUsers = members} >>= getJSON 201
 
-  let q = q0 {filter = isNotifConv conv} :: QueueConsumer
+  let cellsQueue = baseQueue {filter = isNotifConv conv} :: QueueConsumer
 
-  event <- getMessage q %. "payload.0"
+  event <- getMessage cellsQueue %. "payload.0"
   event %. "type" `shouldMatch` "conversation.create"
   event %. "qualified_conversation.id" `shouldMatch` (conv %. "qualified_id.id")
   event %. "qualified_from" `shouldMatch` (alice %. "qualified_id")
 
-  assertNoMessage q
+  assertNoMessage cellsQueue
 
 testCellsFeatureCheck :: (HasCallStack) => App ()
 testCellsFeatureCheck = do
@@ -139,15 +139,15 @@ testCellsFeatureCheck = do
 testCellsEventOnFeatureToggle :: (HasCallStack) => App ()
 testCellsEventOnFeatureToggle = do
   (_, tid, _) <- createTeam OwnDomain 1
-  q <- watchCellsEventsForTeam tid def
+  cellsQueue <- watchCellsEventsForTeam tid def
   I.patchTeamFeature OwnDomain tid "cells" (object ["status" .= "disabled"]) >>= assertSuccess
-  getMessage q >>= \event -> do
+  getMessage cellsQueue >>= \event -> do
     event %. "payload.0.type" `shouldMatch` "feature-config.update"
     event %. "payload.0.name" `shouldMatch` "cells"
     event %. "payload.0.team" `shouldMatch` (asString tid)
     event %. "payload.0.data.status" `shouldMatch` "disabled"
   I.patchTeamFeature OwnDomain tid "cells" (object ["status" .= "enabled"]) >>= assertSuccess
-  getMessage q >>= \event -> do
+  getMessage cellsQueue >>= \event -> do
     event %. "payload.0.type" `shouldMatch` "feature-config.update"
     event %. "payload.0.name" `shouldMatch` "cells"
     event %. "payload.0.team" `shouldMatch` (asString tid)
@@ -166,9 +166,9 @@ testCellsIgnoredEvents = do
   (alice, tid, _) <- createTeam OwnDomain 1
   conv <- postConversation alice defProteus {team = Just tid} >>= getJSON 201
   I.setCellsState alice conv "ready" >>= assertSuccess
-  q <- watchCellsEventsForTeam tid (convEvents conv)
+  cellsQueue <- watchCellsEventsForTeam tid (convEvents conv)
   void $ updateMessageTimer alice conv 1000 >>= getBody 200
-  assertNoMessage q
+  assertNoMessage cellsQueue
 
 --------------------------------------------------------------------------------
 -- Utilities
@@ -211,26 +211,26 @@ connectToCellsQueue sm messages = do
       (cancelConsumer chan)
 
 getNextMessage :: QueueConsumer -> App Value
-getNextMessage q = do
-  m <- liftIO $ atomically $ readTChan q.chan
+getNextMessage cellsQueue = do
+  m <- liftIO $ atomically $ readTChan cellsQueue.chan
   v <- either assertFailure pure $ A.eitherDecode m.msgBody
-  ok <- q.filter v
+  ok <- cellsQueue.filter v
   if ok
     then pure v
-    else getNextMessage q
+    else getNextMessage cellsQueue
 
 getMessageMaybe :: QueueConsumer -> App (Maybe Value)
-getMessageMaybe q = do
+getMessageMaybe cellsQueue = do
   timeOutSeconds <- asks (.timeOutSeconds)
-  next <- appToIO (getNextMessage q)
+  next <- appToIO (getNextMessage cellsQueue)
   liftIO $ timeout (timeOutSeconds * 1000000) next
 
 getMessage :: QueueConsumer -> App Value
-getMessage q = getMessageMaybe q >>= assertJust "Cells queue timeout"
+getMessage cellsQueue = getMessageMaybe cellsQueue >>= assertJust "Cells queue timeout"
 
 assertNoMessage :: QueueConsumer -> App ()
-assertNoMessage f =
-  getMessageMaybe f >>= \case
+assertNoMessage cellsQueue =
+  getMessageMaybe cellsQueue >>= \case
     Nothing -> pure ()
     Just m -> do
       j <- prettyJSON m
@@ -307,8 +307,8 @@ watchCellsEvents opts = do
 
 watchCellsEventsForTeam :: String -> WatchCellsEvents -> App QueueConsumer
 watchCellsEventsForTeam tid opts = do
-  q <- watchCellsEvents opts
+  cellsQueue <- watchCellsEvents opts
   let isEventForTeam v = fieldEquals @Value v "payload.0.team" tid
   -- the cells event queue is shared by tests
   -- let's hope this filter reduces the risk of tests interfering with each other
-  pure $ q {filter = isEventForTeam}
+  pure $ cellsQueue {filter = isEventForTeam}

From c5ec2e7803f65ea438e444cfa2e9c1d8cacc2e07 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Tue, 11 Aug 2026 12:03:51 +0200
Subject: [PATCH 079/113] fix: gate federated conversation create on
 assertFullyConnected (#5430)

---
 integration/test/API/GalleyInternal.hs | 15 +++++++++++++++
 integration/test/Test/Conversation.hs  |  4 ++++
 2 files changed, 19 insertions(+)

diff --git a/integration/test/API/GalleyInternal.hs b/integration/test/API/GalleyInternal.hs
index e6354cf1959..5df6c94d549 100644
--- a/integration/test/API/GalleyInternal.hs
+++ b/integration/test/API/GalleyInternal.hs
@@ -85,6 +85,21 @@ getFederationStatus user domains =
           $ req
           & addJSONObject ["domains" .= domainList]
 
+-- | Poll until the user's backend is fully connected to all given domains
+assertFullyConnected ::
+  ( HasCallStack,
+    MakesValue user
+  ) =>
+  user ->
+  [String] ->
+  App ()
+assertFullyConnected user domains =
+  eventually
+    $ bindResponse (getFederationStatus user domains)
+    $ \resp -> do
+      resp.status `shouldMatchInt` 200
+      resp.json %. "status" `shouldMatch` "fully-connected"
+
 -- | https://staging-nginz-https.zinfra.io/api-internal/swagger-ui/galley/#/galley/put_i_legalhold_whitelisted_teams__tid_
 legalholdWhitelistTeam :: (HasCallStack, MakesValue uid, MakesValue tid) => tid -> uid -> App Response
 legalholdWhitelistTeam tid uid = do
diff --git a/integration/test/Test/Conversation.hs b/integration/test/Test/Conversation.hs
index 3dd5615c667..681df7f59fa 100644
--- a/integration/test/Test/Conversation.hs
+++ b/integration/test/Test/Conversation.hs
@@ -526,6 +526,10 @@ testAddUnreachableUserFromFederatingBackend domain = do
       otherDomain <- make domain & asString
       [alice, bob, charlie, chad] <-
         createAndConnectUsers [ownDomain, otherDomain, cDom.berDomain, cDom.berDomain]
+      -- Wait for ownDomain <-> {fed2, dynamicC} to be fully connected before
+      -- creating the conversation, so a transient fed2 reachability blip under
+      -- dynamic-backend churn does not turn the create into a 533 (WPB-3797).
+      assertFullyConnected alice [otherDomain, cDom.berDomain]
 
       conv <- withWebSockets [bob, charlie] $ \wss -> do
         conv <-

From f9f0da7ef8686700167734b14d9fcce45a022728 Mon Sep 17 00:00:00 2001
From: jschaul 
Date: Tue, 11 Aug 2026 15:40:04 +0200
Subject: [PATCH 080/113] charts/wire-ingress: remove query param from logs
 (#5361)

---
 changelog.d/5-internal/WPB-27370-envoy-logs   |  1 +
 charts/wire-ingress/templates/envoyproxy.yaml | 56 +++++++++++++++----
 2 files changed, 47 insertions(+), 10 deletions(-)
 create mode 100644 changelog.d/5-internal/WPB-27370-envoy-logs

diff --git a/changelog.d/5-internal/WPB-27370-envoy-logs b/changelog.d/5-internal/WPB-27370-envoy-logs
new file mode 100644
index 00000000000..ebf2b02b8a6
--- /dev/null
+++ b/changelog.d/5-internal/WPB-27370-envoy-logs
@@ -0,0 +1 @@
+When using the wire-ingress provided EnvoyProxy, ensure the request path's query string is removed from the logs, to ensure access_tokens passed as query string are not logged. Browsers are required to pass them on a query string on some endpoints such as /await.
diff --git a/charts/wire-ingress/templates/envoyproxy.yaml b/charts/wire-ingress/templates/envoyproxy.yaml
index 12ff6f42b32..974a508e2ac 100644
--- a/charts/wire-ingress/templates/envoyproxy.yaml
+++ b/charts/wire-ingress/templates/envoyproxy.yaml
@@ -1,5 +1,50 @@
 {{- if (and .Values.gateway.create .Values.gateway.envoyProxy.create) }}
 {{- $name := .Values.gateway.envoyProxy.name | default (include "wire-ingress.gatewayName" .) }}
+{{- /*
+  Envoy Gateway access logs include the request path by default, including the
+  query string. We intentionally strip it here because browser-only flows may
+  need to send access_token as a query parameter, and that must never end up in
+  access logs.
+
+  Envoy Gateway requires an explicit sink when accessLog settings are set, so we
+  send the redacted format to /dev/stdout through a File sink.
+*/ -}}
+{{- $accessLogJSON := dict
+  "start_time" "%START_TIME%"
+  "method" "%REQ(:METHOD)%"
+  "x-envoy-origin-path" "%PATH(NQ:ORIG_OR_PATH)%"
+  "protocol" "%PROTOCOL%"
+  "response_code" "%RESPONSE_CODE%"
+  "response_flags" "%RESPONSE_FLAGS%"
+  "response_code_details" "%RESPONSE_CODE_DETAILS%"
+  "connection_termination_details" "%CONNECTION_TERMINATION_DETAILS%"
+  "upstream_transport_failure_reason" "%UPSTREAM_TRANSPORT_FAILURE_REASON%"
+  "bytes_received" "%BYTES_RECEIVED%"
+  "bytes_sent" "%BYTES_SENT%"
+  "duration" "%DURATION%"
+  "x-envoy-upstream-service-time" "%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%"
+  "x-forwarded-for" "%REQ(X-FORWARDED-FOR)%"
+  "user-agent" "%REQ(USER-AGENT)%"
+  "x-request-id" "%REQ(X-REQUEST-ID)%"
+  ":authority" "%REQ(:AUTHORITY)%"
+  "upstream_host" "%UPSTREAM_HOST%"
+  "upstream_cluster" "%UPSTREAM_CLUSTER%"
+  "upstream_local_address" "%UPSTREAM_LOCAL_ADDRESS%"
+  "downstream_local_address" "%DOWNSTREAM_LOCAL_ADDRESS%"
+  "downstream_remote_address" "%DOWNSTREAM_REMOTE_ADDRESS%"
+  "requested_server_name" "%REQUESTED_SERVER_NAME%"
+  "route_name" "%ROUTE_NAME%"
+-}}
+{{- $accessLogSink := dict "type" "File" "file" (dict "path" "/dev/stdout") -}}
+{{- $accessLogSetting := dict "format" (dict "type" "JSON" "json" $accessLogJSON) "sinks" (list $accessLogSink) -}}
+{{- $defaultTelemetry := dict "telemetry" (dict "accessLog" (dict "settings" (list $accessLogSetting))) -}}
+{{- $spec := deepCopy $defaultTelemetry }}
+{{- with .Values.gateway.envoyProxy.spec }}
+{{- $spec = mergeOverwrite $spec . }}
+{{- end }}
+{{- if .Values.gateway.manageServiceType }}
+{{- $_ := set $spec "provider" (dict "type" "Kubernetes" "kubernetes" (dict "envoyService" (dict "type" .Values.gateway.serviceType))) }}
+{{- end }}
 apiVersion: gateway.envoyproxy.io/v1alpha1
 kind: EnvoyProxy
 metadata:
@@ -10,14 +55,5 @@ metadata:
     release: "{{ .Release.Name }}"
     heritage: "{{ .Release.Service }}"
 spec:
-  {{- with .Values.gateway.envoyProxy.spec }}
-  {{- toYaml . | nindent 2 }}
-  {{- end }}
-  {{- if .Values.gateway.manageServiceType }}
-  provider:
-    type: Kubernetes
-    kubernetes:
-      envoyService:
-        type: {{ .Values.gateway.serviceType | quote }}
-  {{- end }}
+{{- toYaml $spec | nindent 2 }}
 {{- end }}

From c1c572bdd24808fec4abd6882ab15253a0284488 Mon Sep 17 00:00:00 2001
From: Sven Tennie 
Date: Tue, 11 Aug 2026 17:16:04 +0200
Subject: [PATCH 081/113] multi-ingress: Use webapp CSP headers (#5432)

The webapp provides several config settings for CSP headers. Duplicating
them here would be pretty tedious and confusing.
As the webapp provides multi-ingress support: Use it!
---
 .../3-bug-fixes/multi-ingress-csp-host-scoping   |  1 +
 .../templates/ingress.yaml                       | 16 +++++++++++++---
 2 files changed, 14 insertions(+), 3 deletions(-)
 create mode 100644 changelog.d/3-bug-fixes/multi-ingress-csp-host-scoping

diff --git a/changelog.d/3-bug-fixes/multi-ingress-csp-host-scoping b/changelog.d/3-bug-fixes/multi-ingress-csp-host-scoping
new file mode 100644
index 00000000000..a0ddaebe61e
--- /dev/null
+++ b/changelog.d/3-bug-fixes/multi-ingress-csp-host-scoping
@@ -0,0 +1 @@
+Fixed Content Security Policy header scoping in multi-ingress Kubernetes configuration. CSP headers set via the Ingress nginx configuration-snippet are now properly scoped to exclude the webapp domain, preventing conflicts with the webapp's own CSP headers that are set independently.
diff --git a/charts/nginx-ingress-services/templates/ingress.yaml b/charts/nginx-ingress-services/templates/ingress.yaml
index 5e613c83d71..c0f1635efdc 100644
--- a/charts/nginx-ingress-services/templates/ingress.yaml
+++ b/charts/nginx-ingress-services/templates/ingress.yaml
@@ -14,8 +14,9 @@ metadata:
     {{/* In the non-multi-ingress (default) case, frontend apps (webapp,
     account-pages, team-settings) set CSP headers on their own. Implementing
     this for multi-ingress would have been much work (due to some framework
-    specifics). Thus, we are setting an approximation of the webapp's CSP
-    headers here. The SAML flow would be broken by setting these headers, so we
+    specifics). Thus, we are setting an approximation of CSP headers here; for
+    all services/apps besides the webapp that provides multi-ingress support
+    for CSP. The SAML flow would be broken by setting these headers, so we
     leave them out for it. This is fine, because in the default case they
     aren't set for any backend API endpoint as well.
 
@@ -37,9 +38,18 @@ metadata:
     `/sso/finalize-login` is at the time of writing also used in Kalium,
     however not versioned. Though, because it is technically possible to use
     the endpoint versioned as well, it cannot hurt to be prepared for this.
+
+    N.B. the nginx config language does not support complex if-expressions -
+    like nested if-s. So, we need to fallback to a more clumsy approach.
     */}}
     nginx.ingress.kubernetes.io/configuration-snippet: |
-      if ($uri !~ "^(/v[0-9]+)?/sso/(finalize-login|initiate-login)(/[a-zA-Z0-9-]*)?$|^/favicon\.ico$") {
+      if ($http_host = "{{ .Values.config.dns.webapp }}") {
+        set $skip_csp 1;
+      }
+      if ($uri ~ "^(/v[0-9]+)?/sso/(finalize-login|initiate-login)(/[a-zA-Z0-9-]*)?$|^/favicon\.ico$") {
+        set $skip_csp 1;
+      }
+      if ($skip_csp != 1) {
         set $CSP "connect-src 'self' blob: data: https://*.giphy.com https://{{ .Values.config.dns.https }}";
         {{if .Values.websockets.enabled}}
         set $CSP "${CSP} wss://{{ .Values.config.dns.ssl }}";

From e6ef9e64be33b5d310c6a8933b2d32fa8cc0d7ad Mon Sep 17 00:00:00 2001
From: Leif Battermann 
Date: Wed, 12 Aug 2026 15:40:25 +0200
Subject: [PATCH 082/113] WPB-26650 prevent fed state drift on prevent
 adminless groups actions (#5425)

Senderless conversation deletes are not sent to remote backends. The owning
backend would delete the conversation, but a remote backend would keep a stale
membership row. The conversation would then still appear in POST
/conversations/list-ids, while GET would return 404 and POST would
/conversations/list omit it.

To be safe, we skip "prevent adminless groups" deletions for groups that have
remote members.

---------

Co-authored-by: Gautier DI FOLCO 
---
 changelog.d/2-features/WPB-26650              |   1 +
 .../src/developer/reference/config-options.md |  15 +++
 integration/test/Test/AdminlessGroups.hs      | 124 ++++++++++++++++++
 .../src/Wire/ConversationSubsystem/Update.hs  | 119 ++++++++++-------
 4 files changed, 212 insertions(+), 47 deletions(-)
 create mode 100644 changelog.d/2-features/WPB-26650

diff --git a/changelog.d/2-features/WPB-26650 b/changelog.d/2-features/WPB-26650
new file mode 100644
index 00000000000..d033ec48bda
--- /dev/null
+++ b/changelog.d/2-features/WPB-26650
@@ -0,0 +1 @@
+Skip senderless prevent-adminless deletion for federated conversations with remote members to prevent remote state drift.
diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md
index 066637dbb07..7ae112d8511 100644
--- a/docs/src/developer/reference/config-options.md
+++ b/docs/src/developer/reference/config-options.md
@@ -362,6 +362,21 @@ The settings mean:
 - `deletionTimeoutDuration`: how long to keep an adminless conversation before it is deleted.
 - `reminderTimeoutDurations`: when before deletion reminder notifications should be sent.
 
+In federated conversations, automatic senderless deletion is skipped when the
+conversation contains remote members because the corresponding system delete
+event cannot yet be sent safely to the remote backend. This applies both when
+the feature is enabled and existing conversations are scanned without an
+origin user, and when a previously scheduled senderless deletion job runs.
+Reminders for a skipped deletion are also skipped because they would be
+misleading. The skipped deletion is logged at info level.
+
+Autopromotion still runs because the conversation-owning backend stores the
+authoritative member roles. Remote clients may miss the immediate senderless
+member-update notification, but a subsequent conversation fetch obtains the
+current role from the owning backend. Member updates and deletions with an
+origin user continue to use the existing ordinary federation events and are
+not skipped.
+
 Durations are strings with a number and a unit suffix. Supported units are `us`, `ms`, `s`, `m`, `h`, `d`, and `w`. It is **not** recommended or supported to set these below a day in production environments.
 
 Feature responses, including `GET /feature-configs`, `GET /teams/:tid/features`, and `GET /teams/:tid/features/preventAdminlessGroups`, include the duration fields:
diff --git a/integration/test/Test/AdminlessGroups.hs b/integration/test/Test/AdminlessGroups.hs
index 05ef119f487..d6c18413f3a 100644
--- a/integration/test/Test/AdminlessGroups.hs
+++ b/integration/test/Test/AdminlessGroups.hs
@@ -21,6 +21,7 @@ import API.Brig
 import API.Galley
 import API.GalleyInternal hiding (getConversation)
 import qualified API.GalleyInternal as GalleyI
+import Control.Concurrent (threadDelay)
 import MLS.Util
 import Notifications
 import SetupHelpers hiding (deleteUser)
@@ -325,6 +326,129 @@ testAdminlessSetupMemberUpdateAfterAdminLeaves = do
       resp.status `shouldMatchInt` 200
       resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin"
 
+testAdminlessSetupDeletesWithOriginAndRemoteMembers :: (HasCallStack) => App ()
+testAdminlessSetupDeletesWithOriginAndRemoteMembers = do
+  (alice, tid, _) <- createTeam OwnDomain 1
+  remoteUser <- randomUser OtherDomain def
+  connectTwoUsers alice remoteUser
+
+  setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked"
+  patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "disabled"]) >>= assertSuccess
+
+  conv <-
+    postConversation
+      alice
+      (defProteus {team = Just tid, qualifiedUsers = [remoteUser], newUsersRole = "wire_member"})
+      >>= getJSON 201
+  convQid <- objQidObject conv
+
+  removeMember alice conv alice >>= assertSuccess
+
+  eventually $ bindResponse (listConversationIds remoteUser def) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+    conversationIds <- resp.json %. "qualified_conversations" & asList
+    conversationIds `shouldContain` [convQid]
+
+  withWebSockets [remoteUser] $ \[wsRemoteUser] -> do
+    setTeamFeatureConfigVersioned (ExplicitVersion 17) alice tid "preventAdminlessGroups" (mkAdminlessFeature "enabled" "1s" []) >>= assertSuccess
+
+    deleteNotif <- awaitMatchFor 20 isConvDeleteNotif wsRemoteUser
+    deleteNotif %. "payload.0.qualified_from" `shouldMatch` objQidObject alice
+
+    eventually $ bindResponse (listConversationIds remoteUser def) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+      conversationIds <- resp.json %. "qualified_conversations" & asList
+      conversationIds `shouldNotContain` [convQid]
+
+testAdminlessSetupSkipsDeletionForRemoteMembers :: (HasCallStack) => App ()
+testAdminlessSetupSkipsDeletionForRemoteMembers = do
+  -- Senderless deletion is skipped when remote members are present because
+  -- remote backends do not support the system delete event yet.
+  (alice, tid, _) <- createTeam OwnDomain 1
+  remoteUser <- randomUser OtherDomain def
+  connectTwoUsers alice remoteUser
+
+  configureAdminlessGroupsFeature OwnDomain tid "disabled" "1s" []
+
+  alice1 <- createMLSClient def alice
+  remoteUser1 <- createMLSClient def remoteUser
+  traverse_ (uploadNewKeyPackage def) [alice1, remoteUser1]
+
+  conv <- createTeamMLSConversation alice tid alice1 [remoteUser]
+
+  -- Create an adminless conversation while the feature is disabled. Enabling
+  -- the feature later exercises the system-triggered setup path.
+  removeMember alice conv alice >>= assertSuccess
+
+  configureAdminlessGroupsFeature OwnDomain tid "enabled" "1s" ["1s"]
+
+  -- The setup job must not schedule deletion or reminders for this
+  -- conversation because it contains a remote member.
+  liftIO $ threadDelay 2_000_000
+  bindResponse (GalleyI.getConversation conv) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+
+testAdminlessSetupSkipsReminderForRemoteMembers :: (HasCallStack) => App ()
+testAdminlessSetupSkipsReminderForRemoteMembers = do
+  -- A remote member prevents senderless deletion. The remaining local app is
+  -- not eligible for promotion, but would receive a system reminder if one
+  -- were emitted.
+  (alice, tid, _) <- createTeam OwnDomain 1
+  remoteUser <- randomUser OtherDomain def
+  connectTwoUsers alice remoteUser
+
+  configureAdminlessGroupsFeature OwnDomain tid "disabled" "5s" ["4s"]
+
+  alice1 <- createMLSClient def alice
+  remoteUser1 <- createMLSClient def remoteUser
+  traverse_ (uploadNewKeyPackage def) [alice1, remoteUser1]
+
+  conv <- createTeamMLSConversation alice tid alice1 [remoteUser]
+  let newApp = def {name = "adminless-federated-reminder-app", description = "not eligible for promotion"}
+  (app, _) <- createAndAddAppMember alice tid alice1 conv newApp
+
+  -- Create an adminless conversation while the feature is disabled. Enabling
+  -- it through the internal path runs senderless setup cleanup.
+  removeMember alice conv alice >>= assertSuccess
+
+  withWebSockets [app] $ \[wsApp] -> do
+    configureAdminlessGroupsFeature OwnDomain tid "enabled" "2s" ["1s"]
+
+    reminderResult <- awaitNMatchesResultFor 5 1 isConvSystemAdminlessReminderNotif wsApp
+    reminderResult.success `shouldMatch` False
+
+    bindResponse (GalleyI.getConversation conv) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+
+testAdminlessSetupAutopromotesWithRemoteMembers :: (HasCallStack) => App ()
+testAdminlessSetupAutopromotesWithRemoteMembers = do
+  -- Autopromotion is safe with remote members because the owning backend is
+  -- authoritative for roles, even though remote clients do not receive the
+  -- senderless system member-update event yet.
+  (alice, tid, [bob]) <- createTeam OwnDomain 2
+  remoteUser <- randomUser OtherDomain def
+  connectTwoUsers alice remoteUser
+
+  configureAdminlessGroupsFeature OwnDomain tid "disabled" "1s" []
+
+  alice1 <- createMLSClient def alice
+  bob1 <- createMLSClient def bob
+  remoteUser1 <- createMLSClient def remoteUser
+  traverse_ (uploadNewKeyPackage def) [alice1, bob1, remoteUser1]
+
+  conv <- createTeamMLSConversation alice tid alice1 [bob, remoteUser]
+
+  -- Create an adminless conversation while the feature is disabled. Enabling
+  -- the feature later promotes Bob through a system action.
+  removeMember alice conv alice >>= assertSuccess
+
+  configureAdminlessGroupsFeature OwnDomain tid "enabled" "1s" []
+
+  liftIO $ threadDelay 2_000_000
+  bindResponse (getConversation bob conv) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+    resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin"
+
 testAdminlessJobsCancelledOnFeatureDisable :: (HasCallStack) => App ()
 testAdminlessJobsCancelledOnFeatureDisable = do
   (alice, tid, _) <- createTeam OwnDomain 1
diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs
index cc840b17839..78474964d8f 100644
--- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs
+++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs
@@ -97,6 +97,7 @@ import Polysemy
 import Polysemy.Error
 import Polysemy.Input
 import Polysemy.TinyLog
+import System.Logger qualified as Log
 import Wire.API.Bot hiding (addBot)
 import Wire.API.Conversation hiding (Member)
 import Wire.API.Conversation.Action
@@ -1195,6 +1196,18 @@ isAdminlessCheckCandidate conv =
   conv.metadata.cnvmType == RegularConv
     && maybe True (== GroupConversation) conv.metadata.cnvmGroupConvType
 
+shouldSkipSystemAdminlessDeletion :: Maybe (Local UserId) -> StoredConversation -> Bool
+shouldSkipSystemAdminlessDeletion mlusr conv =
+  isNothing mlusr
+    && not (null conv.remoteMembers)
+
+logSkippedSystemAdminlessDeletion :: (Member TinyLog r) => Text -> StoredConversation -> Sem r ()
+logSkippedSystemAdminlessDeletion action conv =
+  info $
+    Log.msg (Log.val "Skipping senderless adminless deletion for conversation with remote members")
+      . Log.field "conversation_id" (idToText conv.id_)
+      . Log.field "action" action
+
 setupAdminlessGroupsCleanup ::
   ( Member ConversationStore r,
     Member (ErrorS 'ConvNotFound) r,
@@ -1207,7 +1220,8 @@ setupAdminlessGroupsCleanup ::
     Member BackendNotificationQueueAccess r,
     Member FeaturesConfigSubsystem r,
     Member (Input (Local ())) r,
-    Member JobSubsystem r
+    Member JobSubsystem r,
+    Member TinyLog r
   ) =>
   Maybe (Local UserId) ->
   TeamId ->
@@ -1216,7 +1230,10 @@ setupAdminlessGroupsCleanup mUsr tid = do
   teamConvIds <- E.getTeamConversations tid
   for_ teamConvIds $ \cnv -> do
     lcnv <- qualifyLocal cnv
-    adminlessTryAutopromote mUsr lcnv $ \_ feature _ -> scheduleDeletion lcnv mUsr tid feature
+    adminlessTryAutopromote mUsr lcnv $ \conv feature _ ->
+      if shouldSkipSystemAdminlessDeletion mUsr conv
+        then logSkippedSystemAdminlessDeletion "schedule_for_deletion" conv
+        else scheduleDeletion lcnv mUsr tid feature
 
 guardPreventAdminlessGroups ::
   ( Member ConversationStore r,
@@ -1419,33 +1436,37 @@ adminlessAutopromoteOrDelete ::
     Member BackendNotificationQueueAccess r,
     Member FeaturesConfigSubsystem r,
     Member ProposalStore r,
-    Member CodeStore r
+    Member CodeStore r,
+    Member TinyLog r
   ) =>
   Maybe (Local UserId) ->
   Local ConvId ->
   Sem r ()
 adminlessAutopromoteOrDelete mlusr lcnv = adminlessTryAutopromote mlusr lcnv orAlternativelyDeleteConv
   where
-    orAlternativelyDeleteConv conv _ _ = do
-      removeConversation (qualifyAs lcnv conv)
-      case mlusr of
-        Just lusr ->
-          void $
-            sendConversationActionNotifications
-              (sing @'ConversationDeleteTag)
-              (tUntagged lusr)
-              False
-              Nothing
-              (qualifyAs lcnv conv)
-              (convBotsAndMembers conv)
-              ()
-              def
-        Nothing -> do
-          now <- Now.get
-          Notify.pushSystemEvent
-            Nothing
-            (SystemEvent (tUntagged lcnv) Nothing now conv.metadata.cnvmTeam EdSystemConvDelete)
-            (Set.fromList (map (.id_) conv.localMembers))
+    orAlternativelyDeleteConv conv _ _ =
+      if shouldSkipSystemAdminlessDeletion mlusr conv
+        then logSkippedSystemAdminlessDeletion "deletion" conv
+        else do
+          removeConversation (qualifyAs lcnv conv)
+          case mlusr of
+            Just lusr ->
+              void $
+                sendConversationActionNotifications
+                  (sing @'ConversationDeleteTag)
+                  (tUntagged lusr)
+                  False
+                  Nothing
+                  (qualifyAs lcnv conv)
+                  (convBotsAndMembers conv)
+                  ()
+                  def
+            Nothing -> do
+              now <- Now.get
+              Notify.pushSystemEvent
+                Nothing
+                (SystemEvent (tUntagged lcnv) Nothing now conv.metadata.cnvmTeam EdSystemConvDelete)
+                (Set.fromList (map (.id_) conv.localMembers))
 
 adminlessAutopromoteOrSendReminder ::
   ( Member ConversationStore r,
@@ -1457,7 +1478,8 @@ adminlessAutopromoteOrSendReminder ::
     Member Now r,
     Member E.ExternalAccess r,
     Member BackendNotificationQueueAccess r,
-    Member FeaturesConfigSubsystem r
+    Member FeaturesConfigSubsystem r,
+    Member TinyLog r
   ) =>
   Maybe (Local UserId) ->
   Local ConvId ->
@@ -1465,30 +1487,33 @@ adminlessAutopromoteOrSendReminder ::
   Sem r ()
 adminlessAutopromoteOrSendReminder mlusr lcnv deletionScheduledFor = adminlessTryAutopromote mlusr lcnv orAlternativelySendReminder
   where
-    orAlternativelySendReminder conv _ _ = do
-      now <- Now.get
-      case mlusr of
-        Just lusr -> do
-          let event =
-                Event
-                  (tUntagged lcnv)
-                  Nothing
-                  (EventFromUser (tUntagged lusr))
-                  now
-                  (conv.metadata.cnvmTeam)
-                  (EdAdminlessReminder (AdminlessReminder deletionScheduledFor))
-          pushConversationEvent Nothing conv event (qualifyAs lcnv (map (.id_) conv.localMembers)) []
-        Nothing ->
-          Notify.pushSystemEvent
-            Nothing
-            ( SystemEvent
-                (tUntagged lcnv)
+    orAlternativelySendReminder conv _ _ =
+      if shouldSkipSystemAdminlessDeletion mlusr conv
+        then logSkippedSystemAdminlessDeletion "reminder" conv
+        else do
+          now <- Now.get
+          case mlusr of
+            Just lusr -> do
+              let event =
+                    Event
+                      (tUntagged lcnv)
+                      Nothing
+                      (EventFromUser (tUntagged lusr))
+                      now
+                      (conv.metadata.cnvmTeam)
+                      (EdAdminlessReminder (AdminlessReminder deletionScheduledFor))
+              pushConversationEvent Nothing conv event (qualifyAs lcnv (map (.id_) conv.localMembers)) []
+            Nothing ->
+              Notify.pushSystemEvent
                 Nothing
-                now
-                conv.metadata.cnvmTeam
-                (EdSystemAdminlessReminder (AdminlessReminder deletionScheduledFor))
-            )
-            (Set.fromList (map (.id_) conv.localMembers))
+                ( SystemEvent
+                    (tUntagged lcnv)
+                    Nothing
+                    now
+                    conv.metadata.cnvmTeam
+                    (EdSystemAdminlessReminder (AdminlessReminder deletionScheduledFor))
+                )
+                (Set.fromList (map (.id_) conv.localMembers))
 
 -- Use eight random bytes and fold them into a big-endian Word64. This keeps
 -- the helper small, deterministic under tests, and free of extra Random API.

From 5af74c85fe9ff2e9256d01ee451128f0099fb815 Mon Sep 17 00:00:00 2001
From: Matthias Fischmann 
Date: Fri, 14 Aug 2026 10:00:17 +0200
Subject: [PATCH 083/113] [WPB-27953] SCIM: advertise all schemas used in User
 (fixes RFC compliance issue). (#5441)

---
 ...-used-in-user-_fixes-rfc-compliance-issue_ |  1 +
 .../src/Web/Scim/Capabilities/MetaSchema.hs   | 18 +++++---
 .../hscim/src/Web/Scim/Schema/ResourceType.hs | 46 +++++++++++++++++--
 libs/hscim/src/Web/Scim/Server.hs             |  4 +-
 .../test/Test/Capabilities/MetaSchemaSpec.hs  |  2 +-
 libs/hscim/test/Test/Schema/ResourceSpec.hs   | 31 +++++++++++++
 services/spar/src/Spar/Scim.hs                | 20 +++++++-
 7 files changed, 106 insertions(+), 16 deletions(-)
 create mode 100644 changelog.d/1-api-changes/WPB-27953-scim_-advertise-all-schemas-used-in-user-_fixes-rfc-compliance-issue_

diff --git a/changelog.d/1-api-changes/WPB-27953-scim_-advertise-all-schemas-used-in-user-_fixes-rfc-compliance-issue_ b/changelog.d/1-api-changes/WPB-27953-scim_-advertise-all-schemas-used-in-user-_fixes-rfc-compliance-issue_
new file mode 100644
index 00000000000..0bf2f98a733
--- /dev/null
+++ b/changelog.d/1-api-changes/WPB-27953-scim_-advertise-all-schemas-used-in-user-_fixes-rfc-compliance-issue_
@@ -0,0 +1 @@
+SCIM: advertise all schemas used in User (fixes RFC compliance issue).
diff --git a/libs/hscim/src/Web/Scim/Capabilities/MetaSchema.hs b/libs/hscim/src/Web/Scim/Capabilities/MetaSchema.hs
index 4bdaf265af3..4d8e812f042 100644
--- a/libs/hscim/src/Web/Scim/Capabilities/MetaSchema.hs
+++ b/libs/hscim/src/Web/Scim/Capabilities/MetaSchema.hs
@@ -18,6 +18,7 @@
 module Web.Scim.Capabilities.MetaSchema
   ( ConfigSite,
     configServer,
+    defaultResourceTypes,
     Supported (..),
     BulkConfig (..),
     FilterConfig (..),
@@ -133,11 +134,19 @@ empty =
       authenticationSchemes = [AuthScheme.authHttpBasicEncoding]
     }
 
+-- | The resource types advertised by a server that has not customised them:
+-- the plain @User@ and @Group@ resources with no schema extensions.
+defaultResourceTypes :: [Resource]
+defaultResourceTypes = [usersResource, groupsResource]
+
 configServer ::
   (Monad m) =>
   Configuration ->
+  -- | The resource types to advertise at @/ResourceTypes@.  Pass
+  -- 'defaultResourceTypes' unless the server supports schema extensions.
+  [Resource] ->
   ConfigSite (AsServerT (ScimHandler m))
-configServer config =
+configServer config resourceTypes' =
   ConfigSite
     { spConfig = pure config,
       getSchemas =
@@ -152,12 +161,7 @@ configServer config =
       schema = \uri -> case getSchema (fromSchemaUri uri) of
         Nothing -> throwScim (notFound "Schema" uri)
         Just s -> pure s,
-      resourceTypes =
-        pure $
-          ListResponse.fromList
-            [ usersResource,
-              groupsResource
-            ]
+      resourceTypes = pure $ ListResponse.fromList resourceTypes'
     }
 
 data ConfigSite route = ConfigSite
diff --git a/libs/hscim/src/Web/Scim/Schema/ResourceType.hs b/libs/hscim/src/Web/Scim/Schema/ResourceType.hs
index 2020e3c50a3..37fa5e022c8 100644
--- a/libs/hscim/src/Web/Scim/Schema/ResourceType.hs
+++ b/libs/hscim/src/Web/Scim/Schema/ResourceType.hs
@@ -45,18 +45,52 @@ instance FromJSON ResourceType where
     other -> fail ("unknown ResourceType: " ++ show other)
 
 -- | Definitions of endpoints, returned by @/ResourceTypes@.
+-- | A schema extension advertised by a 'Resource', as defined in RFC 7643
+-- section 6.  Serialises to @{"schema": , "required": }@.
+data SchemaExtension = SchemaExtension
+  { schemaExtensionSchema :: Schema,
+    schemaExtensionRequired :: Bool
+  }
+  deriving (Show, Eq, Generic)
+
+instance ToJSON SchemaExtension where
+  toJSON (SchemaExtension sch req) =
+    object ["schema" .= sch, "required" .= req]
+
+instance FromJSON SchemaExtension where
+  parseJSON = either (fail . show) go . jsonLower
+    where
+      go = withObject "SchemaExtension" $ \o ->
+        SchemaExtension <$> o .: "schema" <*> o .:? "required" .!= False
+
 data Resource = Resource
   { name :: Text,
     endpoint :: URI,
-    schema :: Schema
+    schema :: Schema,
+    schemaExtensions :: [SchemaExtension]
   }
   deriving (Show, Eq, Generic)
 
 instance ToJSON Resource where
-  toJSON = genericToJSON serializeOptions
+  toJSON (Resource name' endpoint' schema' exts) =
+    object $
+      [ "name" .= name',
+        "endpoint" .= endpoint',
+        "schema" .= schema'
+      ]
+        -- omit the field entirely when there are no extensions, so resources
+        -- without extensions keep their previous representation.
+        <> ["schemaExtensions" .= exts | not (null exts)]
 
 instance FromJSON Resource where
-  parseJSON = either (fail . show) (genericParseJSON parseOptions) . jsonLower
+  parseJSON = either (fail . show) go . jsonLower
+    where
+      go = withObject "Resource" $ \o ->
+        Resource
+          <$> o .: "name"
+          <*> o .: "endpoint"
+          <*> o .: "schema"
+          <*> o .:? "schemaextensions" .!= []
 
 ----------------------------------------------------------------------------
 -- Available resource endpoints
@@ -66,7 +100,8 @@ usersResource =
   Resource
     { name = "User",
       endpoint = URI [relativeReference|/Users|],
-      schema = User20
+      schema = User20,
+      schemaExtensions = []
     }
 
 groupsResource :: Resource
@@ -74,5 +109,6 @@ groupsResource =
   Resource
     { name = "Group",
       endpoint = URI [relativeReference|/Groups|],
-      schema = Group20
+      schema = Group20,
+      schemaExtensions = []
     }
diff --git a/libs/hscim/src/Web/Scim/Server.hs b/libs/hscim/src/Web/Scim/Server.hs
index 364f382b0fb..216dccb6594 100644
--- a/libs/hscim/src/Web/Scim/Server.hs
+++ b/libs/hscim/src/Web/Scim/Server.hs
@@ -42,7 +42,7 @@ import Network.Wai
 import Servant
 import Servant.API.Generic
 import Servant.Server.Generic
-import Web.Scim.Capabilities.MetaSchema (ConfigSite, Configuration, configServer)
+import Web.Scim.Capabilities.MetaSchema (ConfigSite, Configuration, configServer, defaultResourceTypes)
 import Web.Scim.Class.Auth (AuthDB (..), AuthTypes (..))
 import Web.Scim.Class.Group (GroupDB, GroupSite (..), GroupTypes (..), groupServer)
 import Web.Scim.Class.User (UserDB (..), UserSite (..), userServer)
@@ -90,7 +90,7 @@ siteServer ::
   Site tag (AsServerT (ScimHandler m))
 siteServer conf =
   Site
-    { config = toServant $ configServer conf,
+    { config = toServant $ configServer conf defaultResourceTypes,
       users = \authData -> toServant (userServer @tag authData),
       groups = \authData -> toServant (groupServer @tag authData)
     }
diff --git a/libs/hscim/test/Test/Capabilities/MetaSchemaSpec.hs b/libs/hscim/test/Test/Capabilities/MetaSchemaSpec.hs
index ec456f4d8e9..67577d4d84d 100644
--- a/libs/hscim/test/Test/Capabilities/MetaSchemaSpec.hs
+++ b/libs/hscim/test/Test/Capabilities/MetaSchemaSpec.hs
@@ -39,7 +39,7 @@ import Web.Scim.Test.Util
 app :: IO Application
 app = do
   storage <- emptyTestStorage
-  pure $ mkapp @Mock (Proxy @ConfigAPI) (toServant (configServer empty)) (nt storage)
+  pure $ mkapp @Mock (Proxy @ConfigAPI) (toServant (configServer empty defaultResourceTypes)) (nt storage)
 
 shouldSatisfy ::
   (Show a, FromJSON a) =>
diff --git a/libs/hscim/test/Test/Schema/ResourceSpec.hs b/libs/hscim/test/Test/Schema/ResourceSpec.hs
index d699201ff9c..365d9613107 100644
--- a/libs/hscim/test/Test/Schema/ResourceSpec.hs
+++ b/libs/hscim/test/Test/Schema/ResourceSpec.hs
@@ -24,6 +24,7 @@ import Data.Aeson
 import HaskellWorks.Hspec.Hedgehog (require)
 import Hedgehog
 import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
 import Test.Hspec
 import Test.Schema.Util (genUri, mk_prop_caseInsensitive)
 import Web.Scim.Schema.ResourceType
@@ -38,15 +39,45 @@ spec :: Spec
 spec = do
   it "roundtrip" $ do
     require prop_roundtrip
+
   it "case-insensitive" $ do
     require $ mk_prop_caseInsensitive genResource
 
+  it "omits schemaExtensions when there are none" $ do
+    toJSON usersResource
+      `shouldBe` object
+        [ "endpoint" .= String "/Users",
+          "name" .= String "User",
+          "schema" .= String "urn:ietf:params:scim:schemas:core:2.0:User"
+        ]
+
+  it "serialises a schema extension in RFC 7643 shape" $ do
+    toJSON (SchemaExtension (Schema.CustomSchema "urn:example:X") True)
+      `shouldBe` object
+        [ "schema" .= String "urn:example:X",
+          "required" .= True
+        ]
+
+  it "user schema with extension also works" $ do
+    toJSON (usersResource {schemaExtensions = [SchemaExtension (Schema.CustomSchema "urn:example:X") True]})
+      `shouldBe` object
+        [ "endpoint" .= String "/Users",
+          "name" .= String "User",
+          "schema" .= String "urn:ietf:params:scim:schemas:core:2.0:User",
+          "schemaExtensions" .= [object ["schema" .= String "urn:example:X", "required" .= True]]
+        ]
+
 genResource :: Gen Resource
 genResource =
   Resource
     <$> Gen.element ["name1", "name2", "name3"]
     <*> genUri
     <*> genSchema
+    <*> Gen.list (Range.linear 0 3) genSchemaExtension
+
+genSchemaExtension :: Gen SchemaExtension
+genSchemaExtension =
+  SchemaExtension <$> genSchema <*> Gen.bool
 
 genSchema :: Gen Schema.Schema
 genSchema =
diff --git a/services/spar/src/Spar/Scim.hs b/services/spar/src/Spar/Scim.hs
index 02e6b5a60c6..3638c6e9043 100644
--- a/services/spar/src/Spar/Scim.hs
+++ b/services/spar/src/Spar/Scim.hs
@@ -59,6 +59,7 @@ module Spar.Scim
 
     -- * API implementation
     apiScim,
+    sparResourceTypes,
   )
 where
 
@@ -91,6 +92,7 @@ import qualified Web.Scim.Class.Group as Scim.Group
 import qualified Web.Scim.Class.User as Scim.User
 import qualified Web.Scim.Handler as Scim
 import qualified Web.Scim.Schema.Error as Scim
+import qualified Web.Scim.Schema.ResourceType as Scim.ResourceType
 import qualified Web.Scim.Schema.Schema as Scim.Schema
 import qualified Web.Scim.Server as Scim
 import Wire.API.Routes.Public.Spar
@@ -190,7 +192,23 @@ server ::
   ScimSite tag (AsServerT (Scim.ScimHandler m))
 server conf =
   ScimSite
-    { config = toServant $ Scim.configServer conf,
+    { config = toServant $ Scim.configServer conf sparResourceTypes,
       users = \authData -> toServant (Scim.userServer @tag authData),
       groups = \authData -> toServant (Scim.groupServer @tag authData)
     }
+
+-- | The SCIM resource types advertised at @/ResourceTypes@.  Unlike the hscim
+-- default, the @User@ resource declares the Wire schema extensions that our user
+-- responses actually contain (see 'userSchemas'), so that the advertised schemas
+-- match the responses as required by RFC 7643 section 6 (issue #5436).
+sparResourceTypes :: [Scim.ResourceType.Resource]
+sparResourceTypes =
+  [ Scim.ResourceType.usersResource
+      { Scim.ResourceType.schemaExtensions =
+          [ Scim.ResourceType.SchemaExtension sch False
+          | sch <- userSchemas,
+            sch /= Scim.Schema.User20
+          ]
+      },
+    Scim.ResourceType.groupsResource
+  ]

From 3f7930cb360a6dc367de16ae47632f871da12d57 Mon Sep 17 00:00:00 2001
From: Matthias Fischmann 
Date: Fri, 14 Aug 2026 10:00:41 +0200
Subject: [PATCH 084/113] [WPB-27953] SCIM: Make role field in user schema
 comply with RFC. (#5440)

---
 ...-role-field-in-user-schema-comply-with-rfc | 25 +++++++
 ...-role-field-in-user-schema-comply-with-rfc |  1 +
 libs/hscim/default.nix                        |  4 ++
 libs/hscim/hscim.cabal                        |  4 ++
 libs/hscim/src/Web/Scim/Schema/Common.hs      | 41 +++++++----
 libs/hscim/src/Web/Scim/Schema/User.hs        |  6 +-
 .../src/Web/Scim/Schema/User/Entitlement.hs   | 55 +++++++++++++++
 libs/hscim/src/Web/Scim/Schema/User/Role.hs   | 55 +++++++++++++++
 libs/hscim/test/Test/Schema/UserSpec.hs       | 69 +++++++++++++++++--
 services/spar/src/Spar/Scim/User.hs           | 30 +++++---
 .../test-integration/Test/Spar/APISpec.hs     | 14 ++--
 .../Test/Spar/Scim/UserSpec.hs                | 14 ++--
 services/spar/test-integration/Util/Scim.hs   | 13 +++-
 13 files changed, 285 insertions(+), 46 deletions(-)
 create mode 100644 changelog.d/0-release-notes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc
 create mode 100644 changelog.d/1-api-changes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc
 create mode 100644 libs/hscim/src/Web/Scim/Schema/User/Entitlement.hs
 create mode 100644 libs/hscim/src/Web/Scim/Schema/User/Role.hs

diff --git a/changelog.d/0-release-notes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc b/changelog.d/0-release-notes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc
new file mode 100644
index 00000000000..bfd6336569b
--- /dev/null
+++ b/changelog.d/0-release-notes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc
@@ -0,0 +1,25 @@
+SCIM: Make role and entitlements fields in user schema comply with RFC.  Any code that processes SCIM users must be changed to follow the standard, instead of the previous Wire implementation.
+
+Previous User schema (incompatible with RFC):
+
+```
+{
+  ...
+  "roles": ["member"],
+  "entitlements": ["some entitlement"],
+  ...
+}
+```
+
+New schema (RFC-compliant):
+
+```
+{
+  ...
+  "roles": [{"value" : "member"}],
+  "entitlements": [{"value" : "some entitlement"}],
+  ...
+}
+```
+
+For backwards compatibility, both fields still accept the old bare-string form on input.
diff --git a/changelog.d/1-api-changes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc b/changelog.d/1-api-changes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc
new file mode 100644
index 00000000000..d9112b27dd6
--- /dev/null
+++ b/changelog.d/1-api-changes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc
@@ -0,0 +1 @@
+SCIM: Make role and entitlements fields in user schema comply with RFC.
diff --git a/libs/hscim/default.nix b/libs/hscim/default.nix
index 82bf4a64981..10e75c4fd29 100644
--- a/libs/hscim/default.nix
+++ b/libs/hscim/default.nix
@@ -31,7 +31,9 @@
 , mmorph
 , mtl
 , network-uri
+, openapi3
 , retry
+, schema-profunctor
 , scientific
 , servant
 , servant-client
@@ -79,7 +81,9 @@ mkDerivation {
     mmorph
     mtl
     network-uri
+    openapi3
     retry
+    schema-profunctor
     scientific
     servant
     servant-client
diff --git a/libs/hscim/hscim.cabal b/libs/hscim/hscim.cabal
index 96d62972d02..0dcc16a742a 100644
--- a/libs/hscim/hscim.cabal
+++ b/libs/hscim/hscim.cabal
@@ -61,10 +61,12 @@ library
     Web.Scim.Schema.User.Address
     Web.Scim.Schema.User.Certificate
     Web.Scim.Schema.User.Email
+    Web.Scim.Schema.User.Entitlement
     Web.Scim.Schema.User.IM
     Web.Scim.Schema.User.Name
     Web.Scim.Schema.User.Phone
     Web.Scim.Schema.User.Photo
+    Web.Scim.Schema.User.Role
     Web.Scim.Schema.UserTypes
     Web.Scim.Server
     Web.Scim.Server.Mock
@@ -114,7 +116,9 @@ library
     , mmorph
     , mtl
     , network-uri
+    , openapi3
     , retry
+    , schema-profunctor
     , scientific
     , servant
     , servant-client
diff --git a/libs/hscim/src/Web/Scim/Schema/Common.hs b/libs/hscim/src/Web/Scim/Schema/Common.hs
index c0adb84c215..67c443d73c4 100644
--- a/libs/hscim/src/Web/Scim/Schema/Common.hs
+++ b/libs/hscim/src/Web/Scim/Schema/Common.hs
@@ -1,5 +1,4 @@
-{-# LANGUAGE DerivingStrategies #-}
-{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE DerivingVia #-}
 {-# LANGUAGE TupleSections #-}
 {-# LANGUAGE ViewPatterns #-}
 
@@ -25,11 +24,15 @@ module Web.Scim.Schema.Common where
 import Data.Aeson
 import qualified Data.Aeson.Key as Key
 import qualified Data.Aeson.KeyMap as KeyMap
+import Data.Aeson.Types (Parser)
 import qualified Data.CaseInsensitive as CI
 import Data.List (nub, (\\))
+import qualified Data.OpenApi as S
+import Data.Schema (NamedSwaggerDoc, Schema (..), ToSchema (..), mkSchema, swaggerDoc)
 import Data.String.Conversions (cs)
 import Data.Text (Text, pack, unpack)
 import qualified Data.Text as Text
+import Lens.Micro ((&), (?~))
 import qualified Network.URI as Network
 
 data WithId id a = WithId
@@ -66,16 +69,30 @@ instance ToJSON URI where
 
 newtype ScimBool = ScimBool {unScimBool :: Bool}
   deriving stock (Eq, Show, Ord)
-  deriving newtype (ToJSON)
-
-instance FromJSON ScimBool where
-  parseJSON (Bool bl) = pure (ScimBool bl)
-  parseJSON (String str) =
-    case CI.mk str of
-      "true" -> pure (ScimBool True)
-      "false" -> pure (ScimBool False)
-      _ -> fail $ "Expected true, false, \"true\", or \"false\" (case insensitive), but got " <> cs str
-  parseJSON bad = fail $ "Expected true, false, \"true\", or \"false\" (case insensitive), but got " <> show bad
+  deriving (FromJSON, ToJSON) via (Schema ScimBool)
+
+-- | Serialises to a plain JSON boolean; parses either a JSON boolean or the
+-- (case-insensitive) strings @"true"@ / @"false"@.
+instance ToSchema ScimBool where
+  schema = mkSchema desc parse (Just . toJSON . unScimBool)
+    where
+      parse :: Value -> Parser ScimBool
+      parse (Bool bl) = pure (ScimBool bl)
+      parse (String str) =
+        case CI.mk str of
+          "true" -> pure (ScimBool True)
+          "false" -> pure (ScimBool False)
+          _ -> fail $ "Expected true, false, \"true\", or \"false\" (case insensitive), but got " <> cs str
+      parse bad = fail $ "Expected true, false, \"true\", or \"false\" (case insensitive), but got " <> show bad
+
+      -- The renderer always produces a JSON boolean, so the schema type is
+      -- @boolean@; the extra string inputs the parser tolerates are documented
+      -- in the description.
+      desc :: NamedSwaggerDoc
+      desc =
+        swaggerDoc @Bool
+          & (S.schema . S.description)
+            ?~ "On input, the JSON strings \"true\" and \"false\" (case-insensitive) are also accepted."
 
 toKeyword :: String -> String
 toKeyword "typ" = "type"
diff --git a/libs/hscim/src/Web/Scim/Schema/User.hs b/libs/hscim/src/Web/Scim/Schema/User.hs
index 1a37f6dae60..c2f32deefff 100644
--- a/libs/hscim/src/Web/Scim/Schema/User.hs
+++ b/libs/hscim/src/Web/Scim/Schema/User.hs
@@ -90,10 +90,12 @@ import Web.Scim.Schema.Schema (Schema (..), getSchemaUri)
 import Web.Scim.Schema.User.Address (Address)
 import Web.Scim.Schema.User.Certificate (Certificate)
 import Web.Scim.Schema.User.Email (Email)
+import Web.Scim.Schema.User.Entitlement (Entitlement)
 import Web.Scim.Schema.User.IM (IM)
 import Web.Scim.Schema.User.Name (Name)
 import Web.Scim.Schema.User.Phone (Phone)
 import Web.Scim.Schema.User.Photo (Photo)
+import Web.Scim.Schema.User.Role (Role)
 import Web.Scim.Schema.UserTypes
 
 -- | SCIM user record, parametrized with type-level @tag@ (see 'UserTypes').
@@ -119,8 +121,8 @@ data User tag = User
     ims :: [IM],
     photos :: [Photo],
     addresses :: [Address],
-    entitlements :: [Text],
-    roles :: [Text],
+    entitlements :: [Entitlement],
+    roles :: [Role],
     x509Certificates :: [Certificate],
     -- Extra data.
     --
diff --git a/libs/hscim/src/Web/Scim/Schema/User/Entitlement.hs b/libs/hscim/src/Web/Scim/Schema/User/Entitlement.hs
new file mode 100644
index 00000000000..c0ca03eadc2
--- /dev/null
+++ b/libs/hscim/src/Web/Scim/Schema/User/Entitlement.hs
@@ -0,0 +1,55 @@
+{-# LANGUAGE DerivingVia #-}
+
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Web.Scim.Schema.User.Entitlement where
+
+import Data.Aeson (FromJSON (..), ToJSON, Value (String))
+import qualified Data.OpenApi as S
+import Data.Schema
+import Data.Text (Text)
+import Web.Scim.Schema.Common (ScimBool (..))
+
+-- | A SCIM @entitlements@ entry. RFC 7643 defines @entitlements@ as a complex,
+-- multi-valued attribute, so each element is an object with (optional)
+-- sub-attributes rather than a bare string.
+data Entitlement = Entitlement
+  { value :: Maybe Text,
+    typ :: Maybe Text,
+    display :: Maybe Text,
+    primary :: Maybe ScimBool
+  }
+  deriving stock (Show, Eq)
+  deriving (ToJSON, S.ToSchema) via (Schema Entitlement)
+
+instance ToSchema Entitlement where
+  schema =
+    object
+      ( Entitlement
+          <$> (value .= maybe_ (optField "value" schema))
+          <*> (typ .= maybe_ (optField "type" schema))
+          <*> (display .= maybe_ (optField "display" schema))
+          <*> (primary .= maybe_ (optField "primary" schema))
+      )
+
+-- | We accept both the RFC-compliant object form (parsed via the schema above)
+-- and a plain string (for backwards compatibility with clients that send
+-- @"entitlements": ["some entitlement"]@).
+instance FromJSON Entitlement where
+  parseJSON (String s) = pure $ Entitlement (Just s) Nothing Nothing Nothing
+  parseJSON v = schemaIn (schema @Entitlement) v
diff --git a/libs/hscim/src/Web/Scim/Schema/User/Role.hs b/libs/hscim/src/Web/Scim/Schema/User/Role.hs
new file mode 100644
index 00000000000..f18ccf8362e
--- /dev/null
+++ b/libs/hscim/src/Web/Scim/Schema/User/Role.hs
@@ -0,0 +1,55 @@
+{-# LANGUAGE DerivingVia #-}
+
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Web.Scim.Schema.User.Role where
+
+import Data.Aeson (FromJSON (..), ToJSON, Value (String))
+import qualified Data.OpenApi as S
+import Data.Schema
+import Data.Text (Text)
+import Web.Scim.Schema.Common (ScimBool (..))
+
+-- | A SCIM @roles@ entry. RFC 7643 defines @roles@ as a complex, multi-valued
+-- attribute, so each element is an object with (optional) sub-attributes rather
+-- than a bare string.
+data Role = Role
+  { value :: Maybe Text,
+    typ :: Maybe Text,
+    display :: Maybe Text,
+    primary :: Maybe ScimBool
+  }
+  deriving stock (Show, Eq)
+  deriving (ToJSON, S.ToSchema) via (Schema Role)
+
+instance ToSchema Role where
+  schema =
+    object
+      ( Role
+          <$> (value .= maybe_ (optField "value" schema))
+          <*> (typ .= maybe_ (optField "type" schema))
+          <*> (display .= maybe_ (optField "display" schema))
+          <*> (primary .= maybe_ (optField "primary" schema))
+      )
+
+-- | We accept both the RFC-compliant object form (parsed via the schema above)
+-- and a plain string (for backwards compatibility with clients that send
+-- @"roles": ["member"]@).
+instance FromJSON Role where
+  parseJSON (String s) = pure $ Role (Just s) Nothing Nothing Nothing
+  parseJSON v = schemaIn (schema @Role) v
diff --git a/libs/hscim/test/Test/Schema/UserSpec.hs b/libs/hscim/test/Test/Schema/UserSpec.hs
index 1885060facc..5aea6a427d8 100644
--- a/libs/hscim/test/Test/Schema/UserSpec.hs
+++ b/libs/hscim/test/Test/Schema/UserSpec.hs
@@ -52,10 +52,12 @@ import qualified Web.Scim.Schema.User as User
 import Web.Scim.Schema.User.Address as Address
 import Web.Scim.Schema.User.Certificate as Certificate
 import Web.Scim.Schema.User.Email as Email
+import Web.Scim.Schema.User.Entitlement as Entitlement
 import Web.Scim.Schema.User.IM as IM
 import Web.Scim.Schema.User.Name as Name
 import Web.Scim.Schema.User.Phone as Phone
 import Web.Scim.Schema.User.Photo as Photo
+import Web.Scim.Schema.User.Role as Role
 import Web.Scim.Test.Util
 
 prop_roundtrip :: Property
@@ -116,6 +118,7 @@ spec = do
           let operation = Operation Replace (Just (NormalPath (AttrPath Nothing key Nothing))) (Just upd)
           let patchOp = PatchOp [operation]
           User.applyPatch user patchOp `shouldSatisfy` isRight
+
     it "does not support multi-value attributes" $ do
       let schemas' = []
       let extras = KeyMap.empty
@@ -135,13 +138,14 @@ spec = do
           ("ims", toJSON @[IM] mempty),
           ("photos", toJSON @[Photo] mempty),
           ("addresses", toJSON @[Address] mempty),
-          ("entitlements", toJSON @[Text] mempty),
+          ("entitlements", toJSON @[Entitlement] mempty),
           ("x509Certificates", toJSON @[Certificate] mempty)
         ]
         $ \(key, upd) -> do
           let operation = Operation Replace (Just (NormalPath (AttrPath Nothing key Nothing))) (Just upd)
           let patchOp = PatchOp [operation]
           User.applyPatch user patchOp `shouldSatisfy` isLeft
+
     it "applies patch to `extra`" $ do
       let schemas' = []
       let extras = KeyMap.empty
@@ -150,33 +154,66 @@ spec = do
       let operation = Operation Replace (Just programmingLanguagePath) (Just (toJSON @Text "haskell"))
       let patchOp = PatchOp [operation]
       User.extra <$> User.applyPatch user patchOp `shouldBe` Right (KeyMap.singleton "programmingLanguage" "haskell")
+
   describe "JSON serialization" $ do
     it "handles all fields" $ do
       require prop_roundtrip
       toJSON completeUser `shouldBe` completeUserJson
       eitherDecode (encode completeUserJson) `shouldBe` Right completeUser
+
     it "has defaults for all optional and multi-valued fields" $ do
       toJSON minimalUser `shouldBe` minimalUserJson
       eitherDecode (encode minimalUserJson) `shouldBe` Right minimalUser
-    it "treats 'null' and '[]' as absence of fields" $
+
+    it "treats 'null' and '[]' as absence of fields" $ do
       eitherDecode (encode minimalUserJsonRedundant)
         `shouldBe` Right minimalUser
+
     it "allows casing variations in field names" $ do
       require $ mk_prop_caseInsensitive genUser
       require $ mk_prop_caseInsensitive (ListResponse.fromList . (: []) <$> genStoredUser)
       eitherDecode (encode minimalUserJsonNonCanonical) `shouldBe` Right minimalUser
-    it "doesn't require the 'schemas' field" $
+
+    it "doesn't require the 'schemas' field" $ do
       eitherDecode (encode minimalUserJsonNoSchemas)
         `shouldBe` Right minimalUser
+
     it "doesn't add 'extra' if it's an empty object" $ do
       toJSON (extendedUser UserExtraEmpty) `shouldBe` extendedUserEmptyJson
       eitherDecode (encode extendedUserEmptyJson)
         `shouldBe` Right (extendedUser UserExtraEmpty)
+
     it "encodes and decodes 'extra' correctly" $ do
       toJSON (extendedUser (UserExtraObject "foo")) `shouldBe` extendedUserObjectJson
       eitherDecode (encode extendedUserObjectJson)
         `shouldBe` Right (extendedUser (UserExtraObject "foo"))
 
+  describe "roles (RFC 7643 complex multi-valued attribute)" $ do
+    it "renders as objects with a 'value' sub-attribute, not bare strings" $ do
+      toJSON (Role (Just "member") Nothing Nothing Nothing)
+        `shouldBe` [scim| {"value": "member"} |]
+
+    it "parses the RFC-compliant object form" $ do
+      eitherDecode "{\"value\":\"member\"}"
+        `shouldBe` Right (Role (Just "member") Nothing Nothing Nothing)
+
+    it "still parses the legacy bare-string form for backwards compatibility" $ do
+      eitherDecode "\"member\""
+        `shouldBe` Right (Role (Just "member") Nothing Nothing Nothing)
+
+  describe "entitlements (RFC 7643 complex multi-valued attribute)" $ do
+    it "renders as objects with a 'value' sub-attribute, not bare strings" $ do
+      toJSON (Entitlement (Just "some entitlement") Nothing Nothing Nothing)
+        `shouldBe` [scim| {"value": "some entitlement"} |]
+
+    it "parses the RFC-compliant object form" $ do
+      eitherDecode "{\"value\":\"some entitlement\"}"
+        `shouldBe` Right (Entitlement (Just "some entitlement") Nothing Nothing Nothing)
+
+    it "still parses the legacy bare-string form for backwards compatibility" $ do
+      eitherDecode "\"some entitlement\""
+        `shouldBe` Right (Entitlement (Just "some entitlement") Nothing Nothing Nothing)
+
 genName :: Gen Name
 genName =
   Name
@@ -320,8 +357,22 @@ completeUser =
               Address.primary = Just (ScimBool True)
             }
         ],
-      entitlements = ["sample entitlement"],
-      roles = ["sample role"],
+      entitlements =
+        [ Entitlement
+            { Entitlement.value = Just "sample entitlement",
+              Entitlement.typ = Nothing,
+              Entitlement.display = Nothing,
+              Entitlement.primary = Nothing
+            }
+        ],
+      roles =
+        [ Role
+            { Role.value = Just "sample role",
+              Role.typ = Nothing,
+              Role.display = Nothing,
+              Role.primary = Nothing
+            }
+        ],
       x509Certificates =
         [ Certificate
             { Certificate.typ = Just "sample certificate type",
@@ -337,7 +388,9 @@ completeUserJson =
   [scim|
 {
   "roles": [
-    "sample role"
+    {
+      "value": "sample role"
+    }
   ],
   "x509Certificates": [
     {
@@ -388,7 +441,9 @@ completeUserJson =
   ],
   "preferredLanguage": "da, en-gb;q=0.8, en;q=0.7",
   "entitlements": [
-    "sample entitlement"
+    {
+      "value": "sample entitlement"
+    }
   ],
   "displayName": "sample displayName",
   "nickName": "sample nickName",
diff --git a/services/spar/src/Spar/Scim/User.hs b/services/spar/src/Spar/Scim/User.hs
index a8b54e2de02..28c47848a91 100644
--- a/services/spar/src/Spar/Scim/User.hs
+++ b/services/spar/src/Spar/Scim/User.hs
@@ -97,6 +97,7 @@ import qualified Web.Scim.Schema.ResourceType as Scim
 import qualified Web.Scim.Schema.User as Scim
 import qualified Web.Scim.Schema.User as Scim.User (schemas)
 import qualified Web.Scim.Schema.User.Email as Scim.Email
+import qualified Web.Scim.Schema.User.Role as Scim.Role
 import qualified Wire.API.Team.Member as Member
 import Wire.API.Team.Role
 import Wire.API.User
@@ -334,11 +335,13 @@ validateScimUser' errloc midp richInfoLimit user = do
     validateRole =
       Scim.roles <&> \case
         [] -> pure Nothing
-        [role] ->
-          maybe
-            (throw $ badRequest $ "The role '" <> role <> "' is not valid. Valid roles are " <> validRoleNames <> ".")
-            (pure . Just)
-            (fromByteString $ Text.encodeUtf8 role)
+        [role] -> case Scim.Role.value role of
+          Nothing -> throw $ badRequest "A role must have a value."
+          Just roleNm ->
+            maybe
+              (throw $ badRequest $ "The role '" <> roleNm <> "' is not valid. Valid roles are " <> validRoleNames <> ".")
+              (pure . Just)
+              (fromByteString $ Text.encodeUtf8 roleNm)
         (_ : _ : _) -> throw $ badRequest "A user cannot have more than one role."
 
     badRequest :: Text -> Scim.ScimError
@@ -1086,10 +1089,19 @@ synthesizeScimUser info =
           Scim.roles =
             maybe
               []
-              ( (: [])
-                  . Text.decodeUtf8With lenientDecode
-                  . toStrict
-                  . toByteString
+              ( \role ->
+                  [ Scim.Role.Role
+                      { Scim.Role.value =
+                          Just
+                            . Text.decodeUtf8With lenientDecode
+                            . toStrict
+                            . toByteString
+                            $ role,
+                        Scim.Role.typ = Nothing,
+                        Scim.Role.display = Nothing,
+                        Scim.Role.primary = Nothing
+                      }
+                  ]
               )
               (info.role),
           Scim.emails = (\e -> Scim.Email.Email Nothing (Scim.Email.EmailAddress e) Nothing) <$> info.emails
diff --git a/services/spar/test-integration/Test/Spar/APISpec.hs b/services/spar/test-integration/Test/Spar/APISpec.hs
index 315d798378c..949b2d2e8b8 100644
--- a/services/spar/test-integration/Test/Spar/APISpec.hs
+++ b/services/spar/test-integration/Test/Spar/APISpec.hs
@@ -1351,7 +1351,7 @@ specProvisionScimAndSAMLUserWithRole = do
       let testCreateUserWithRole role = do
             scimUser <- do
               u <- ScimT.randomScimUser
-              pure $ u {Scim.roles = [cs $ toByteString $ role]}
+              pure $ u {Scim.roles = ScimT.mkScimRoles [cs $ toByteString $ role]}
             uid <- ScimT.scimUserId <$> ScimT.createUser tok scimUser
             ScimT.checkTeamMembersRole tid owner uid role
       mapM_ testCreateUserWithRole [minBound .. maxBound]
@@ -1366,7 +1366,7 @@ specProvisionScimAndSAMLUserWithRole = do
       (tok, _) <- ScimT.registerIdPAndScimTokenWithMeta
       scimUser <- do
         u <- ScimT.randomScimUser
-        pure $ u {Scim.roles = ["member", "admin"]}
+        pure $ u {Scim.roles = ScimT.mkScimRoles ["member", "admin"]}
       ScimT.createUser' tok scimUser !!! do
         const 400 === statusCode
         const (Just "A user cannot have more than one role.") =~= responseBody
@@ -1374,7 +1374,7 @@ specProvisionScimAndSAMLUserWithRole = do
       (tok, _) <- ScimT.registerIdPAndScimTokenWithMeta
       scimUser <- do
         u <- ScimT.randomScimUser
-        pure $ u {Scim.roles = ["president"]}
+        pure $ u {Scim.roles = ScimT.mkScimRoles ["president"]}
       ScimT.createUser' tok scimUser !!! do
         const 400 === statusCode
         const (Just "The role 'president' is not valid. Valid roles are owner, admin, member, partner.") =~= responseBody
@@ -1383,7 +1383,7 @@ specProvisionScimAndSAMLUserWithRole = do
       scimUserWithDefaultRole <- ScimT.randomScimUser
       uid <- ScimT.scimUserId <$> ScimT.createUser tok scimUserWithDefaultRole
       let testUpdateUserWithRole role = do
-            let scimUserWithRole = scimUserWithDefaultRole {Scim.roles = [cs $ toByteString $ role]}
+            let scimUserWithRole = scimUserWithDefaultRole {Scim.roles = ScimT.mkScimRoles [cs $ toByteString $ role]}
             _ <- ScimT.updateUser tok uid scimUserWithRole
             ScimT.checkTeamMembersRole tid owner uid role
       mapM_ testUpdateUserWithRole [minBound .. maxBound]
@@ -1393,7 +1393,7 @@ specProvisionScimAndSAMLUserWithRole = do
           testUpdateUserWithDefaultRole role = do
             scimUser <- do
               u <- ScimT.randomScimUser
-              pure $ u {Scim.roles = [cs $ toByteString $ role]}
+              pure $ u {Scim.roles = ScimT.mkScimRoles [cs $ toByteString $ role]}
             uid <- ScimT.scimUserId <$> ScimT.createUser tok scimUser
             _ <- ScimT.updateUser tok uid (scimUser {Scim.roles = []})
             ScimT.checkTeamMembersRole tid owner uid role
@@ -1402,14 +1402,14 @@ specProvisionScimAndSAMLUserWithRole = do
       (tok, _) <- ScimT.registerIdPAndScimTokenWithMeta
       scimUser <- ScimT.randomScimUser
       uid <- ScimT.scimUserId <$> ScimT.createUser tok scimUser
-      ScimT.updateUser' tok uid (scimUser {Scim.roles = ["admin", "member"]}) !!! do
+      ScimT.updateUser' tok uid (scimUser {Scim.roles = ScimT.mkScimRoles ["admin", "member"]}) !!! do
         const 400 === statusCode
         const (Just "A user cannot have more than one role.") =~= responseBody
     it "updated user - fail if role name cannot be parsed correctly" $ do
       (tok, _) <- ScimT.registerIdPAndScimTokenWithMeta
       scimUser <- ScimT.randomScimUser
       uid <- ScimT.scimUserId <$> ScimT.createUser tok scimUser
-      ScimT.updateUser' tok uid (scimUser {Scim.roles = ["hamlet"]}) !!! do
+      ScimT.updateUser' tok uid (scimUser {Scim.roles = ScimT.mkScimRoles ["hamlet"]}) !!! do
         const 400 === statusCode
         const (Just "The role 'hamlet' is not valid. Valid roles are owner, admin, member, partner.") =~= responseBody
 
diff --git a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs
index 545f1ff8977..84bc06c13d8 100644
--- a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs
+++ b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs
@@ -668,7 +668,7 @@ testCreateUserNoIdPInvalidRoles = do
     randomScimUser <&> \u ->
       u
         { Scim.User.externalId = Just $ fromEmail email,
-          Scim.User.roles = cs . toByteString <$> [RoleMember, RoleExternalPartner]
+          Scim.User.roles = mkScimRoles $ cs . toByteString <$> [RoleMember, RoleExternalPartner]
         }
   createUser' tok scimUserTooManyRoles !!! do
     const 400 === statusCode
@@ -677,7 +677,7 @@ testCreateUserNoIdPInvalidRoles = do
     randomScimUser <&> \u ->
       u
         { Scim.User.externalId = Just $ fromEmail email,
-          Scim.User.roles = ["foobar"]
+          Scim.User.roles = mkScimRoles ["foobar"]
         }
   createUser' tok scimUserInvalidRole !!! do
     const 400 === statusCode
@@ -699,7 +699,7 @@ testCreateUserNoIdPWithRole brig tid owner tok role = do
     randomScimUser <&> \u ->
       u
         { Scim.User.externalId = Just $ fromEmail email,
-          Scim.User.roles = [cs $ toByteString role]
+          Scim.User.roles = mkScimRoles [cs $ toByteString role]
         }
   scimStoredUser <- createUser tok scimUser
   let userid = scimUserId scimStoredUser
@@ -713,7 +713,7 @@ testCreateUserNoIdPWithRole brig tid owner tok role = do
     -- FUTUREWORK: if this is not the desired behavior, have to handle this in the `getUser` handler:
     -- - if the user has a pending invitation, we have to look up the role in the invitation table
     --   by doing an rpc to brig
-    liftIO $ Scim.User.roles usr `shouldBe` [cs $ toByteString defaultRole]
+    liftIO $ scimRoleValues (Scim.User.roles usr) `shouldBe` [cs $ toByteString defaultRole]
     -- now external ID can differ from email, so emails are also returned
     liftIO $ (\(Scim.Email.Email _ e _) -> Scim.Email.unEmailAddress e) <$> Scim.User.emails usr `shouldBe` [email]
     liftIO $ Scim.User.externalId usr `shouldBe` (Just (fromEmail email))
@@ -1975,7 +1975,7 @@ testUpdateUserRole = do
         randomScimUser <&> \u ->
           u
             { Scim.User.externalId = Just $ fromEmail email,
-              Scim.User.roles = [cs $ toByteString initialRole]
+              Scim.User.roles = mkScimRoles [cs $ toByteString initialRole]
             }
       scimStoredUser <- createUser tok scimUser
       let userid = scimUserId scimStoredUser
@@ -1987,7 +1987,7 @@ testUpdateUserRole = do
         Just inviteeCode <- call $ getInvitationCode brig tid inv.invitationId
         registerInvitation email userName inviteeCode True
       checkTeamMembersRole tid owner userid initialRole
-      _ <- updateUser tok userid (scimUser {Scim.User.roles = cs . toByteString <$> maybeToList mUpdatedRole})
+      _ <- updateUser tok userid (scimUser {Scim.User.roles = mkScimRoles $ cs . toByteString <$> maybeToList mUpdatedRole})
       checkTeamMembersRole tid owner userid targetRoleExpected
 
 ----------------------------------------------------------------------------
@@ -2210,7 +2210,7 @@ createScimUserWithRole brig tid owner tok initialRole = do
     randomScimUser <&> \u ->
       u
         { Scim.User.externalId = Just $ fromEmail email,
-          Scim.User.roles = [cs $ toByteString initialRole]
+          Scim.User.roles = mkScimRoles [cs $ toByteString initialRole]
         }
   scimStoredUser <- createUser tok scimUser
   let userid = scimUserId scimStoredUser
diff --git a/services/spar/test-integration/Util/Scim.hs b/services/spar/test-integration/Util/Scim.hs
index 9f8d1b1c023..0599fa64039 100644
--- a/services/spar/test-integration/Util/Scim.hs
+++ b/services/spar/test-integration/Util/Scim.hs
@@ -62,6 +62,7 @@ import qualified Web.Scim.Schema.User as Scim
 import qualified Web.Scim.Schema.User as Scim.User
 import qualified Web.Scim.Schema.User.Email as Scim.Email
 import qualified Web.Scim.Schema.User.Phone as Phone
+import qualified Web.Scim.Schema.User.Role as Scim.Role
 import qualified Wire.API.Team.Member as Member
 import Wire.API.Team.Role (Role, defaultRole)
 import Wire.API.User
@@ -69,6 +70,14 @@ import Wire.API.User.IdentityProvider hiding (handle, team)
 import Wire.API.User.RichInfo
 import Wire.API.User.Scim
 
+-- | Build a SCIM @roles@ list (RFC 7643 complex form) from plain role-name strings.
+mkScimRoles :: [Text] -> [Scim.Role.Role]
+mkScimRoles = map (\v -> Scim.Role.Role (Just v) Nothing Nothing Nothing)
+
+-- | Extract the role-name strings from a SCIM @roles@ list.
+scimRoleValues :: [Scim.Role.Role] -> [Text]
+scimRoleValues = mapMaybe Scim.Role.value
+
 -- | Take apart a 'ValidScimId', using 'SAML.UserRef' if available, otherwise 'Email'.
 runValidScimIdEither :: (SAML.UserRef -> a) -> (EmailAddress -> a) -> ValidScimId -> a
 runValidScimIdEither doUref doEmail = these doEmail doUref (\_ uref -> doUref uref) . validScimIdAuthInfo
@@ -162,7 +171,7 @@ randomScimUserWithSubjectAndRichInfo richInfo = do
           Scim.User.externalId = Just externalId,
           Scim.User.emails = [],
           Scim.User.phoneNumbers = phones,
-          Scim.User.roles = ["member"]
+          Scim.User.roles = mkScimRoles ["member"]
           -- if we don't add this role here explicitly, some tests may show confusing failures
           -- involving [] or null being changed to ["member"] during a create or update
           -- operation.
@@ -724,7 +733,7 @@ setDefaultRoleAndEmailsIfEmpty :: Scim.User.User a -> Scim.User.User a
 setDefaultRoleAndEmailsIfEmpty u =
   u
     { Scim.User.roles = case Scim.User.roles u of
-        [] -> [cs $ toByteString' defaultRole]
+        [] -> mkScimRoles [cs $ toByteString' defaultRole]
         xs -> xs,
       -- when the emails field is empty, we try to populate it with the externalId
       Scim.User.emails = case Scim.User.emails u of

From 07c5dad02fe3b2c9505e6a5d0df633275ac68ff2 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Fri, 14 Aug 2026 14:10:27 +0200
Subject: [PATCH 085/113] fix(federator): record mock-federator requests
 atomically (#5437)

---
 services/federator/default.nix                   |  1 +
 services/federator/federator.cabal               |  1 +
 services/federator/src/Federator/MockServer.hs   |  2 +-
 .../federator/test/unit/Test/Federator/Client.hs | 16 ++++++++++++++++
 4 files changed, 19 insertions(+), 1 deletion(-)

diff --git a/services/federator/default.nix b/services/federator/default.nix
index fa8eac96bda..e0d3e9d7235 100644
--- a/services/federator/default.nix
+++ b/services/federator/default.nix
@@ -168,6 +168,7 @@ mkDerivation {
   ];
   testHaskellDepends = [
     aeson
+    async
     base
     bytestring
     bytestring-conversion
diff --git a/services/federator/federator.cabal b/services/federator/federator.cabal
index 872f9911236..2a8181f5f18 100644
--- a/services/federator/federator.cabal
+++ b/services/federator/federator.cabal
@@ -380,6 +380,7 @@ test-suite federator-tests
 
   build-depends:
       aeson
+    , async
     , base
     , bytestring
     , bytestring-conversion
diff --git a/services/federator/src/Federator/MockServer.hs b/services/federator/src/Federator/MockServer.hs
index 828a87bcb1a..31094692c8f 100644
--- a/services/federator/src/Federator/MockServer.hs
+++ b/services/federator/src/Federator/MockServer.hs
@@ -151,7 +151,7 @@ mockInternalRequest remoteCalls mock targetDomain component (RPC path) req cont
     if path == "api-version"
       then pure $ MockResponse Wai.status200 "application/json" (Aeson.encode (VersionInfo mock.versions))
       else do
-        modifyIORef remoteCalls (<> [fedRequest])
+        atomicModifyIORef' remoteCalls (\xs -> (xs <> [fedRequest], ()))
         fromException @MockException
           . handle (throw . handleException)
           $ mock.handler fedRequest
diff --git a/services/federator/test/unit/Test/Federator/Client.hs b/services/federator/test/unit/Test/Federator/Client.hs
index 8bc976fe6a9..0660390b393 100644
--- a/services/federator/test/unit/Test/Federator/Client.hs
+++ b/services/federator/test/unit/Test/Federator/Client.hs
@@ -35,6 +35,7 @@
 
 module Test.Federator.Client (tests) where
 
+import Control.Concurrent.Async (replicateConcurrently_)
 import Control.Exception hiding (handle)
 import Control.Monad.Codensity
 import Control.Monad.Except
@@ -92,6 +93,7 @@ tests =
       testGroup
         "HTTP2 client"
         [ testCase "testResponseHeaders" testResponseHeaders,
+          testCase "testConcurrentRequestsAllRecorded" testConcurrentRequestsAllRecorded,
           testCase "testStreaming" testStreaming
         ]
     ]
@@ -248,6 +250,20 @@ testResponseHeaders = do
       responseStatusCode resp @?= HTTP.status200
       lookup "X-Foo" (toList (responseHeaders resp)) @?= Just "bar"
 
+testConcurrentRequestsAllRecorded :: IO ()
+testConcurrentRequestsAllRecorded = do
+  (_, sentRequests) <-
+    withTempMockFederator def $ \port -> do
+      let req =
+            HTTP2.requestBuilder
+              HTTP.methodPost
+              "/rpc/target.example.com/brig/test"
+              [("Wire-Origin-Domain", "origin.example.com"), (federationRequestIdHeaderName, "rid")]
+              "body"
+      mgr <- defaultHttp2Manager
+      replicateConcurrently_ 50 (performHTTP2Request mgr (False, "127.0.0.1", port) req)
+  length sentRequests @?= 50
+
 testStreaming :: IO ()
 testStreaming = withInfiniteMockServer $ \port -> do
   let req = HTTP2.requestBuilder HTTP.methodPost "test" [] mempty

From da5bf667a06e29b0038636434cfc0e15a770d54e Mon Sep 17 00:00:00 2001
From: Jan Schumacher <155645800+jschumacher-wire@users.noreply.github.com>
Date: Mon, 17 Aug 2026 12:19:55 +0200
Subject: [PATCH 086/113] reaper: updating kubectl image, detection script,
 rbac (#5444)

* reaper: updating kubectl image, detection script, rbac

* list images (hip-15)

* updating release notes & readme

---------

Co-authored-by: Stefan Matting 
---
 .../0-release-notes/reaper-image-and-rbac     | 12 ++++
 charts/reaper/Chart.yaml                      |  5 ++
 charts/reaper/README.md                       | 55 +++++++++++++++
 charts/reaper/scripts/reaper.sh               | 68 +++++++++++--------
 charts/reaper/templates/_helpers.tpl          | 26 +++++++
 charts/reaper/templates/deployment.yaml       | 43 ++++++++++--
 charts/reaper/templates/rbac.yaml             | 47 +++++++++----
 charts/reaper/values.yaml                     | 38 ++++++++++-
 8 files changed, 246 insertions(+), 48 deletions(-)
 create mode 100644 changelog.d/0-release-notes/reaper-image-and-rbac
 create mode 100644 charts/reaper/templates/_helpers.tpl

diff --git a/changelog.d/0-release-notes/reaper-image-and-rbac b/changelog.d/0-release-notes/reaper-image-and-rbac
new file mode 100644
index 00000000000..a974b94ca38
--- /dev/null
+++ b/changelog.d/0-release-notes/reaper-image-and-rbac
@@ -0,0 +1,12 @@
+The `reaper` chart no longer grants itself `cluster-admin` and no longer uses an
+unmaintained container image. Upgrading is a drop-in `helm upgrade`; no manual steps.
+
+Two cases need action:
+
+* If you override `image` in your values, update the override: the default changed from
+  `docker.io/bitnamilegacy/kubectl:1.32.4` to `docker.io/alpine/kubectl:1.36.3`. The
+  image must contain a POSIX shell at `/bin/sh` — distroless kubectl images do not work.
+* If you mirror images into a private registry (airgapped installs), add the new image.
+
+See `charts/reaper/README.md` for the image settings, the RBAC the chart now creates,
+and the rest of the changes.
diff --git a/charts/reaper/Chart.yaml b/charts/reaper/Chart.yaml
index 2d8bd97f867..131654fa443 100644
--- a/charts/reaper/Chart.yaml
+++ b/charts/reaper/Chart.yaml
@@ -3,3 +3,8 @@ version: 0.0.42
 name: reaper
 appVersion: 0.1.0
 description: A helm charts to restart cannons if redis-ephemeal has died
+annotations:
+  # must conform to https://github.com/helm/community/blob/main/hips/hip-0015.md
+  helm.sh/images: |
+    - name: kubectl
+      image: docker.io/alpine/kubectl:1.36.3
diff --git a/charts/reaper/README.md b/charts/reaper/README.md
index 0a0e0b2652c..f4b73e4e670 100644
--- a/charts/reaper/README.md
+++ b/charts/reaper/README.md
@@ -14,3 +14,58 @@ messages. Here, this reaper will check that the `redis-ephemeral` pod is older t
 other `cannon`; if that is not the case, it kills the `cannon`s forcing clients to
 reconnect.
 
+Image
+-----
+
+The reaper runs `scripts/reaper.sh` through `kubectl`, so `image` must point at a
+kubectl image that **contains a POSIX shell** at `/bin/sh`. Distroless kubectl images
+do not ship one and the pod will fail to start. The script itself is POSIX sh, so
+busybox `ash` is enough, bash not required.
+
+The image is fully configurable:
+
+```yaml
+image:
+  registry: docker.io      # set to "" for an unqualified repository
+  repository: alpine/kubectl
+  tag: 1.36.3
+  digest: ""               # e.g. "sha256:..."; takes precedence over tag
+  pullPolicy: IfNotPresent
+imagePullSecrets:
+  - name: my-pull-secret
+```
+
+RBAC
+----
+
+The chart creates a namespaced `Role`/`RoleBinding` granting `get`, `list`, `watch` and
+`delete` on pods, bound to a `-reaper` ServiceAccount.
+
+`watch` is required even though the script never watches anything explicitly:
+`kubectl delete pod` blocks until the pod is gone and opens a watch to do so. Without it
+the reaper deletes the first cannon and then hangs, without crashing.
+
+Earlier versions bound the ServiceAccount to `cluster-admin` through a fixed-name
+`ClusterRoleBinding`, which gave the pod read access to every Secret in the cluster.
+`helm upgrade` removes that binding and the old `reaper-role` ServiceAccount. Because
+nothing is cluster-scoped any more and all names are release-scoped, several reaper
+releases can now coexist in one cluster; previously a second release failed to install
+with a `ClusterRoleBinding` ownership conflict.
+
+Runtime
+-------
+
+The container runs as uid/gid 65534 with a read-only root filesystem and has resource
+requests and limits. `nodeSelector`, `tolerations` and `affinity` are honoured.
+
+`checkIntervalSeconds` (default `15`) controls how long the script waits between checks.
+Earlier versions listed pods once per second.
+
+Logs distinguish a failure to reach the API from "there are no matching pods", and
+include the underlying error:
+
+    Failed to list pods: Error from server (Forbidden): ... Skipping this iteration...
+    No cannon pods found. Doing nothing...
+
+Both cases previously printed `Failed to list pods. Skipping this iteration...`, so a
+reaper that could not list pods at all looked exactly like an idle one.
diff --git a/charts/reaper/scripts/reaper.sh b/charts/reaper/scripts/reaper.sh
index d0f2679354e..f67049e76a6 100755
--- a/charts/reaper/scripts/reaper.sh
+++ b/charts/reaper/scripts/reaper.sh
@@ -1,27 +1,34 @@
-#!/usr/bin/env bash
+#!/bin/sh
 
 # See the readme of the reaper chart.
+#
+# This is POSIX sh on purpose: the only actively maintained kubectl images that
+# ship busybox ash, not bash.
 
 # we loop forever, and on transient errors sleep and try again.
 # setting -e would crash the pod on transient e.g. network errors, which isn't useful.
-set -uo pipefail
+set -u
+# shellcheck disable=SC3040 # busybox ash supports pipefail
+set -o pipefail
 
-USAGE="$0 "
+USAGE="$0  [INTERVAL_SECONDS]"
 NAMESPACE="${1:?$USAGE}"
+INTERVAL="${2:-15}"
 
-echo "Using namespace: $NAMESPACE"
+echo "Using namespace: $NAMESPACE, check interval: ${INTERVAL}s"
 
 kill_all_cannons() {
   echo "Killing all cannons"
-  CANNON_PODS=$(kubectl -n "$NAMESPACE" get pods 2>/dev/null \
-    | grep -e "cannon" \
-    | awk '{ print $1 }') || {
-    echo "Failed to list cannon pods. Skipping this iteration..."
+  RAW_PODS=$(kubectl -n "$NAMESPACE" get pods 2>&1) || {
+    echo "Failed to list cannon pods: $RAW_PODS. Skipping this iteration..."
     return
   }
+  CANNON_PODS=$(echo "$RAW_PODS" | grep -e "cannon" | awk '{ print $1 }') || CANNON_PODS=""
 
+  # A here-document rather than a pipeline, so the loop runs in the current
+  # shell and the `exit 1` below actually terminates the script.
   while IFS= read -r cannon; do
-    if [[ -n "$cannon" ]]; then
+    if [ -n "$cannon" ]; then
       echo "Deleting $cannon"
       # If a single delete fails, we skip it but keep going.
       kubectl -n "$NAMESPACE" delete pod "$cannon" || {
@@ -29,29 +36,33 @@ kill_all_cannons() {
         exit 1
       }
     fi
-  done <<< "$CANNON_PODS"
+  done </dev/null \
-    | grep -e "cannon" -e "redis-ephemeral") || {
-      echo "Failed to list pods. Skipping this iteration..."
-      sleep 60
-      continue
+  # List first, filter second. Folding both into one pipeline made an API failure.
+  RAW_PODS=$(kubectl -n "$NAMESPACE" get pods --sort-by=.metadata.creationTimestamp 2>&1) || {
+    echo "Failed to list pods: $RAW_PODS. Skipping this iteration..."
+    sleep "$INTERVAL"
+    continue
   }
 
+  # Gather all pods that contain "cannon" or "redis-ephemeral", sorted by creation time
+  ALL_PODS=$(echo "$RAW_PODS" | grep -e "cannon" -e "redis-ephemeral") || ALL_PODS=""
+
   # Check if we have any cannon pods at all
   if ! echo "$ALL_PODS" | grep -q "cannon"; then
     echo "No cannon pods found. Doing nothing..."
-    sleep 60
+    sleep "$INTERVAL"
     continue
   fi
 
   # Check if we have any redis-ephemeral pods at all
   if ! echo "$ALL_PODS" | grep -q "redis-ephemeral"; then
     echo "No redis-ephemeral pod found. Doing nothing..."
-    sleep 60
+    sleep "$INTERVAL"
     continue
   fi
 
@@ -59,19 +70,20 @@ while true; do
   # Check which is oldest
   FIRST_POD=$(echo "$ALL_PODS" | head -n 1 | awk '{ print $1 }')
 
-  if [[ -z "$FIRST_POD" ]]; then
+  if [ -z "$FIRST_POD" ]; then
     echo "Could not determine the oldest pod from the list. Doing nothing..."
-    sleep 60
+    sleep "$INTERVAL"
     continue
   fi
 
-  if [[ "$FIRST_POD" =~ "redis-ephemeral" ]]; then
-    echo "redis-ephemeral is the oldest pod, all good."
-  else
-    kill_all_cannons
-  fi
+  case "$FIRST_POD" in
+    *redis-ephemeral*)
+      echo "redis-ephemeral is the oldest pod, all good."
+      ;;
+    *)
+      kill_all_cannons
+      ;;
+  esac
 
-  echo "Sleep 1"
-  sleep 1
+  sleep "$INTERVAL"
 done
-
diff --git a/charts/reaper/templates/_helpers.tpl b/charts/reaper/templates/_helpers.tpl
new file mode 100644
index 00000000000..47fc05fa161
--- /dev/null
+++ b/charts/reaper/templates/_helpers.tpl
@@ -0,0 +1,26 @@
+{{/* Allow KubeVersion to be overridden. */}}
+{{- define "kubeVersion" -}}
+  {{- default .Capabilities.KubeVersion.Version .Values.kubeVersionOverride -}}
+{{- end -}}
+
+{{- define "includeSecurityContext" -}}
+  {{- (semverCompare ">= 1.24-0" (include "kubeVersion" .)) -}}
+{{- end -}}
+
+{{/* Fully qualified image reference, digest taking precedence over tag. */}}
+{{- define "reaper.image" -}}
+{{- $repository := .Values.image.repository -}}
+{{- if .Values.image.registry -}}
+{{- $repository = printf "%s/%s" .Values.image.registry .Values.image.repository -}}
+{{- end -}}
+{{- if .Values.image.digest -}}
+{{- printf "%s@%s" $repository .Values.image.digest -}}
+{{- else -}}
+{{- printf "%s:%s" $repository (.Values.image.tag | toString) -}}
+{{- end -}}
+{{- end -}}
+
+{{/* Release-scoped name for the ServiceAccount, Role and RoleBinding. */}}
+{{- define "reaper.serviceAccountName" -}}
+{{- printf "%s-reaper" .Release.Name | trunc 63 | trimSuffix "-" -}}
+{{- end -}}
diff --git a/charts/reaper/templates/deployment.yaml b/charts/reaper/templates/deployment.yaml
index f9f8fb6d9b7..9d50439dc5c 100644
--- a/charts/reaper/templates/deployment.yaml
+++ b/charts/reaper/templates/deployment.yaml
@@ -18,8 +18,16 @@ spec:
       labels:
         app: reaper
         release: {{ .Release.Name }}
+      annotations:
+        # Ensure changes to the script cause a redeployment upon `helm upgrade`
+        checksum/configmap: {{ include (print .Template.BasePath "/configmap.yaml") . | sha256sum }}
     spec:
-      serviceAccountName: reaper-role
+      serviceAccountName: {{ include "reaper.serviceAccountName" . }}
+      automountServiceAccountToken: true
+      {{- with .Values.imagePullSecrets }}
+      imagePullSecrets:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
       topologySpreadConstraints:
         - maxSkew: 1
           topologyKey: "kubernetes.io/hostname"
@@ -29,12 +37,38 @@ spec:
               app: reaper
       containers:
         - name: reaper
-          image: {{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }}
-          command: ["/app/reaper.sh", "{{ .Release.Namespace }}"]
+          image: {{ include "reaper.image" . | quote }}
+          imagePullPolicy: {{ default "" .Values.image.pullPolicy | quote }}
+          command: ["/bin/sh", "/app/reaper.sh", "{{ .Release.Namespace }}", "{{ .Values.checkIntervalSeconds }}"]
+        {{- if eq (include "includeSecurityContext" .) "true" }}
+          securityContext:
+            {{- toYaml .Values.podSecurityContext | nindent 12 }}
+        {{- end }}
+          env:
+            # kubectl writes its discovery cache below $HOME; the root
+            # filesystem is read-only, so point it at the emptyDir.
+            - name: HOME
+              value: /tmp
           volumeMounts:
             - name: reaper-script
               mountPath: /app
               readOnly: true
+            - name: tmp
+              mountPath: /tmp
+          resources:
+{{ toYaml .Values.resources | indent 12 }}
+      {{- with .Values.nodeSelector }}
+      nodeSelector:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+      {{- with .Values.tolerations }}
+      tolerations:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
+      {{- with .Values.affinity }}
+      affinity:
+        {{- toYaml . | nindent 8 }}
+      {{- end }}
       volumes:
         - name: reaper-script
           configMap:
@@ -43,4 +77,5 @@ spec:
             items:
               - key: reaper.sh
                 path: reaper.sh
-
+        - name: tmp
+          emptyDir: {}
diff --git a/charts/reaper/templates/rbac.yaml b/charts/reaper/templates/rbac.yaml
index 83862bb012b..5e4caafb6f2 100644
--- a/charts/reaper/templates/rbac.yaml
+++ b/charts/reaper/templates/rbac.yaml
@@ -1,17 +1,38 @@
-kind: ClusterRoleBinding
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: {{ include "reaper.serviceAccountName" . }}
+  labels:
+    app: reaper
+    release: {{ .Release.Name }}
+---
+# The reaper only ever lists and deletes pods in its own namespace, so a
+# namespaced Role is sufficient.
+# `watch` is required even though the script never watches anything explicitly.
+kind: Role
 apiVersion: rbac.authorization.k8s.io/v1
 metadata:
-  name: reaper-role
-subjects:
-- kind: ServiceAccount
-  name: reaper-role
-  namespace: {{ .Release.Namespace }}
-roleRef:
-  kind: ClusterRole
-  name: cluster-admin
-  apiGroup: rbac.authorization.k8s.io
+  name: {{ include "reaper.serviceAccountName" . }}
+  labels:
+    app: reaper
+    release: {{ .Release.Name }}
+rules:
+  - apiGroups: [""]
+    resources: ["pods"]
+    verbs: ["get", "list", "watch", "delete"]
 ---
-apiVersion: v1
-kind: ServiceAccount
+kind: RoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
 metadata:
-  name: reaper-role
+  name: {{ include "reaper.serviceAccountName" . }}
+  labels:
+    app: reaper
+    release: {{ .Release.Name }}
+roleRef:
+  kind: Role
+  name: {{ include "reaper.serviceAccountName" . }}
+  apiGroup: rbac.authorization.k8s.io
+subjects:
+  - kind: ServiceAccount
+    name: {{ include "reaper.serviceAccountName" . }}
+    namespace: {{ .Release.Namespace }}
diff --git a/charts/reaper/values.yaml b/charts/reaper/values.yaml
index fdc3fb5b46f..cf2f56cf9e4 100644
--- a/charts/reaper/values.yaml
+++ b/charts/reaper/values.yaml
@@ -1,13 +1,45 @@
 image:
-  # Use a kubectl image that includes a shell (sh/bash). Distroless images will fail to exec the script.
+  # The reaper executes a shell script through kubectl, so this image must
+  # contain a POSIX shell at /bin/sh. Distroless kubectl images do not
+  # ship one and the pod will fail to start with them.
+  #
+  # Set `registry` to "" to use an unqualified repository (e.g. when mirroring
+  # into a registry configured as the daemon default).
   registry: docker.io
-  repository: bitnamilegacy/kubectl
-  tag: 1.32.4
+  repository: alpine/kubectl
+  tag: 1.36.3
+  # Optional: pin by digest (e.g. "sha256:abc..."). Takes precedence over `tag`.
+  digest: ""
+  pullPolicy: IfNotPresent
+
+imagePullSecrets: []
+
+# How long to wait between two checks, in seconds. The condition this chart
+# watches for (a redis-ephemeral restart) is rare, so there is no reason to poll
+# the API server aggressively.
+checkIntervalSeconds: 15
+
+resources:
+  requests:
+    memory: 32Mi
+    cpu: 10m
+  limits:
+    memory: 64Mi
+
+nodeSelector: {}
+tolerations: []
+affinity: {}
+
+# Applied as the container securityContext. runAsUser/runAsGroup are set
+# explicitly because alpine/kubectl runs as root by default.
 podSecurityContext:
   allowPrivilegeEscalation: false
   capabilities:
     drop:
       - ALL
+  readOnlyRootFilesystem: true
   runAsNonRoot: true
+  runAsUser: 65534
+  runAsGroup: 65534
   seccompProfile:
     type: RuntimeDefault

From 19af0ba908fcf2ab56172748b5dc03fb198b6fa0 Mon Sep 17 00:00:00 2001
From: Matthias Fischmann 
Date: Mon, 17 Aug 2026 13:27:01 +0200
Subject: [PATCH 087/113] [WPB-27953] Make scim error responses comply with
 RFC7644. (#5439)

Co-authored-by: Sven Tennie 
---
 ...e-scim-error-responses-comply-with-rfc7644 |  22 ++
 ...e-scim-error-responses-comply-with-rfc7644 |   1 +
 services/spar/spar.cabal                      |   1 +
 services/spar/src/Spar/Error.hs               |   8 +-
 .../test-integration/Test/Spar/APISpec.hs     |  24 +-
 .../Test/Spar/Scim/UserSpec.hs                | 215 +++++++++++++-----
 services/spar/test-integration/Util/Scim.hs   |  34 +++
 services/spar/test/Test/Spar/ErrorSpec.hs     |  55 +++++
 8 files changed, 304 insertions(+), 56 deletions(-)
 create mode 100644 changelog.d/0-release-notes/WPB-27953-make-scim-error-responses-comply-with-rfc7644
 create mode 100644 changelog.d/1-api-changes/WPB-27953-make-scim-error-responses-comply-with-rfc7644
 create mode 100644 services/spar/test/Test/Spar/ErrorSpec.hs

diff --git a/changelog.d/0-release-notes/WPB-27953-make-scim-error-responses-comply-with-rfc7644 b/changelog.d/0-release-notes/WPB-27953-make-scim-error-responses-comply-with-rfc7644
new file mode 100644
index 00000000000..d769a62ddfb
--- /dev/null
+++ b/changelog.d/0-release-notes/WPB-27953-make-scim-error-responses-comply-with-rfc7644
@@ -0,0 +1,22 @@
+Make SCIM error responses comply with RFC7644.  Any code that processes SCIM error responses must be changed to follow the standard, instead of the previous Wire implementation.
+
+Previous schema (incompatible with RFC):
+
+```
+{
+  "code": 400,
+  "label": "scim-error",
+  "message": "{\"detail\":\"[...]\",\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:Error\"],\"scimType\":\"invalidValue\",\"status\":\"400\"}"
+}
+```
+
+New schema (RFC-compliant):
+
+```
+{
+  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
+  "status": "400"
+  "scimType": "invalidValue",
+  "detail": "[...]",
+}
+```
diff --git a/changelog.d/1-api-changes/WPB-27953-make-scim-error-responses-comply-with-rfc7644 b/changelog.d/1-api-changes/WPB-27953-make-scim-error-responses-comply-with-rfc7644
new file mode 100644
index 00000000000..f03aeea44f6
--- /dev/null
+++ b/changelog.d/1-api-changes/WPB-27953-make-scim-error-responses-comply-with-rfc7644
@@ -0,0 +1 @@
+Make scim error responses comply with RFC7644.
diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal
index 8bd4d03aac8..962388000ed 100644
--- a/services/spar/spar.cabal
+++ b/services/spar/spar.cabal
@@ -530,6 +530,7 @@ test-suite spec
     Paths_spar
     Test.Spar.APISpec
     Test.Spar.DataSpec
+    Test.Spar.ErrorSpec
     Test.Spar.Intra.BrigSpec
     Test.Spar.Roundtrip.ByteString
     Test.Spar.Saml.IdPSpec
diff --git a/services/spar/src/Spar/Error.hs b/services/spar/src/Spar/Error.hs
index f15cb5dfad0..a2cd61d9f73 100644
--- a/services/spar/src/Spar/Error.hs
+++ b/services/spar/src/Spar/Error.hs
@@ -135,7 +135,13 @@ sparToServerErrorWithLogging logger err = do
   pure errServant
 
 sparToServerError :: SparError -> ServerError
-sparToServerError = httpErrorToServerError . renderSparError
+-- SCIM errors have their own response format (RFC 7644, section 3.12): the body
+-- must be the bare SCIM error object.  Going through 'renderSparError' /
+-- 'httpErrorToServerError' would instead nest it into a wire-server 'Wai.Error'
+-- ('{"code":..,"label":"scim-error","message":}'), so we
+-- render it directly here.
+sparToServerError (SAML.CustomError (SparScimError err)) = Scim.scimToServerError err
+sparToServerError err = httpErrorToServerError (renderSparError err)
 
 waiToServant :: Wai.Error -> ServerError
 waiToServant waierr =
diff --git a/services/spar/test-integration/Test/Spar/APISpec.hs b/services/spar/test-integration/Test/Spar/APISpec.hs
index 949b2d2e8b8..dd7935d210f 100644
--- a/services/spar/test-integration/Test/Spar/APISpec.hs
+++ b/services/spar/test-integration/Test/Spar/APISpec.hs
@@ -1369,7 +1369,11 @@ specProvisionScimAndSAMLUserWithRole = do
         pure $ u {Scim.roles = ScimT.mkScimRoles ["member", "admin"]}
       ScimT.createUser' tok scimUser !!! do
         const 400 === statusCode
-        const (Just "A user cannot have more than one role.") =~= responseBody
+        ScimT.mkScimErrorResp
+          (Just "A user cannot have more than one role. (post)")
+          (Just "invalidValue")
+          "400"
+          === responseBody
     it "create user - fail if role name cannot be parsed correctly" $ do
       (tok, _) <- ScimT.registerIdPAndScimTokenWithMeta
       scimUser <- do
@@ -1377,7 +1381,11 @@ specProvisionScimAndSAMLUserWithRole = do
         pure $ u {Scim.roles = ScimT.mkScimRoles ["president"]}
       ScimT.createUser' tok scimUser !!! do
         const 400 === statusCode
-        const (Just "The role 'president' is not valid. Valid roles are owner, admin, member, partner.") =~= responseBody
+        ScimT.mkScimErrorResp
+          (Just "The role 'president' is not valid. Valid roles are owner, admin, member, partner. (post)")
+          (Just "invalidValue")
+          "400"
+          === responseBody
     it "update user" $ do
       (tok, (owner, tid, _idp, (_, _privcreds))) <- ScimT.registerIdPAndScimTokenWithMeta
       scimUserWithDefaultRole <- ScimT.randomScimUser
@@ -1404,14 +1412,22 @@ specProvisionScimAndSAMLUserWithRole = do
       uid <- ScimT.scimUserId <$> ScimT.createUser tok scimUser
       ScimT.updateUser' tok uid (scimUser {Scim.roles = ScimT.mkScimRoles ["admin", "member"]}) !!! do
         const 400 === statusCode
-        const (Just "A user cannot have more than one role.") =~= responseBody
+        ScimT.mkScimErrorResp
+          (Just "A user cannot have more than one role. (put)")
+          (Just "invalidValue")
+          "400"
+          === responseBody
     it "updated user - fail if role name cannot be parsed correctly" $ do
       (tok, _) <- ScimT.registerIdPAndScimTokenWithMeta
       scimUser <- ScimT.randomScimUser
       uid <- ScimT.scimUserId <$> ScimT.createUser tok scimUser
       ScimT.updateUser' tok uid (scimUser {Scim.roles = ScimT.mkScimRoles ["hamlet"]}) !!! do
         const 400 === statusCode
-        const (Just "The role 'hamlet' is not valid. Valid roles are owner, admin, member, partner.") =~= responseBody
+        ScimT.mkScimErrorResp
+          (Just "The role 'hamlet' is not valid. Valid roles are owner, admin, member, partner. (put)")
+          (Just "invalidValue")
+          "400"
+          === responseBody
 
 specAux :: SpecWith TestEnv
 specAux = do
diff --git a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs
index 84bc06c13d8..211e434e3bd 100644
--- a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs
+++ b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs
@@ -36,7 +36,6 @@ import Control.Monad.Except (MonadError (throwError))
 import Control.Monad.Random (randomRIO)
 import Control.Monad.Trans.Except
 import Control.Monad.Trans.Maybe
-import qualified Data.Aeson
 import qualified Data.Aeson as Aeson
 import Data.Aeson.Lens (key, _String)
 import Data.Aeson.QQ (aesonQQ)
@@ -56,7 +55,6 @@ import Data.Text.Encoding (decodeUtf8, encodeUtf8)
 import qualified Data.Vector as V
 import qualified Data.ZAuth.Token as ZAuth
 import Imports
-import qualified Network.Wai.Utilities.Error as Wai
 import Polysemy
 import Polysemy.Error
 import qualified SAML2.WebSSO as SAML
@@ -652,9 +650,11 @@ testCreateUserWithPass = do
   user <- randomScimUser <&> \u -> u {Scim.User.password = Just "geheim"}
   createUser_ (Just tok) user (env ^. teSpar) !!! do
     const 400 === statusCode
-    -- TODO: write a FAQ entry in wire-docs, reference it in the error description.
-    -- TODO: yes, we should just test for error labels consistently, i know...
-    const (Just "Setting user passwords is not supported for security reasons.") =~= responseBody
+    mkScimErrorResp
+      (Just "Setting user passwords is not supported for security reasons. (post)")
+      (Just "invalidValue")
+      "400"
+      === responseBody
 
 testCreateUserNoIdPInvalidRoles :: TestSpar ()
 testCreateUserNoIdPInvalidRoles = do
@@ -672,7 +672,11 @@ testCreateUserNoIdPInvalidRoles = do
         }
   createUser' tok scimUserTooManyRoles !!! do
     const 400 === statusCode
-    const (Just "A user cannot have more than one role.") =~= responseBody
+    mkScimErrorResp
+      (Just "A user cannot have more than one role. (post)")
+      (Just "invalidValue")
+      "400"
+      === responseBody
   scimUserInvalidRole <-
     randomScimUser <&> \u ->
       u
@@ -681,7 +685,11 @@ testCreateUserNoIdPInvalidRoles = do
         }
   createUser' tok scimUserInvalidRole !!! do
     const 400 === statusCode
-    const (Just "The role 'foobar' is not valid. Valid roles are owner, admin, member, partner.") =~= responseBody
+    mkScimErrorResp
+      (Just "The role 'foobar' is not valid. Valid roles are owner, admin, member, partner. (post)")
+      (Just "invalidValue")
+      "400"
+      === responseBody
 
 testCreateUserNoIdPWithRoles :: TestSpar ()
 testCreateUserNoIdPWithRoles = do
@@ -854,6 +862,11 @@ testCreateUserNoIdPNoEmail = do
   user <- randomScimUser <&> \u -> u {Scim.User.externalId = Just "notanemail"}
   createUser_ (Just tok) user (env ^. teSpar) !!! do
     const 400 === statusCode
+    mkScimErrorResp
+      (Just "Could not process externalId. Please check: (1) does the scim user contain a valid email address? (2) did you associate your scim token with a SAML IdP in wire?")
+      (Just "invalidValue")
+      "400"
+      === responseBody
 
 testCreateUserWithSamlIdP :: TestSpar ()
 testCreateUserWithSamlIdP = do
@@ -937,8 +950,13 @@ testExternalIdIsRequired = do
   user <- randomScimUser
   let user' = user {Scim.User.externalId = Nothing}
   (tok, _) <- registerIdPAndScimToken
-  createUser_ (Just tok) user' (env ^. teSpar)
-    !!! const 400 === statusCode
+  createUser_ (Just tok) user' (env ^. teSpar) !!! do
+    const 400 === statusCode
+    mkScimErrorResp
+      (Just "externalId is required")
+      (Just "invalidValue")
+      "400"
+      === responseBody
 
 -- The next line contains a mapping from this test to the following test standards:
 -- @SF.Provisioning @TSFI.RESTfulAPI @S2
@@ -950,8 +968,10 @@ testCreateRejectsInvalidHandle = do
   -- Create a user via SCIM
   user <- randomScimUser
   (tok, _) <- registerIdPAndScimToken
-  createUser_ (Just tok) (user {Scim.User.userName = "#invalid name"}) (env ^. teSpar)
-    !!! const 400 === statusCode
+  createUser_ (Just tok) (user {Scim.User.userName = "#invalid name"}) (env ^. teSpar) !!! do
+    const 400 === statusCode
+    mkScimErrorResp Nothing (Just "invalidValue") "400"
+      === responseBody
 
 -- @END
 
@@ -967,11 +987,22 @@ testCreateRejectsTakenHandle = do
   -- Create and add a first user: success!
   _ <- createUser tokTeamA user1
   -- Try to create different user with same handle in same team.
-  createUser_ (Just tokTeamA) (user2 {Scim.User.userName = Scim.User.userName user1}) (env ^. teSpar)
-    !!! const 409 === statusCode
+  createUser_ (Just tokTeamA) (user2 {Scim.User.userName = Scim.User.userName user1}) (env ^. teSpar) !!! do
+    const 409 === statusCode
+    mkScimErrorResp
+      (Just "userName is already taken")
+      (Just "uniqueness")
+      "409"
+      === responseBody
+
   -- Try to create different user with same handle in different team.
-  createUser_ (Just tokTeamB) (user3 {Scim.User.userName = Scim.User.userName user1}) (env ^. teSpar)
-    !!! const 409 === statusCode
+  createUser_ (Just tokTeamB) (user3 {Scim.User.userName = Scim.User.userName user1}) (env ^. teSpar) !!! do
+    const 409 === statusCode
+    mkScimErrorResp
+      (Just "userName is already taken")
+      (Just "uniqueness")
+      "409"
+      === responseBody
 
 -- | Test that user creation fails if the @externalId@ is already in use for given IdP.
 testCreateRejectsTakenExternalId :: Bool -> TestSpar ()
@@ -993,8 +1024,10 @@ testCreateRejectsTakenExternalId withidp = do
   _ <- createUser tok user1
   -- Try to create different user with same @externalId@ in same team, and fail.
   user2 <- randomScimUser
-  createUser_ (Just tok) (user2 {Scim.User.externalId = Scim.User.externalId user1}) (env ^. teSpar)
-    !!! const 409 === statusCode
+  createUser_ (Just tok) (user2 {Scim.User.externalId = Scim.User.externalId user1}) (env ^. teSpar) !!! do
+    const 409 === statusCode
+    mkScimErrorResp Nothing (Just "uniqueness") "409"
+      === responseBody
 
 -- | Test that it's fine to have same @externalId@s for two users belonging to different IdPs.
 testCreateSameExternalIds :: TestSpar ()
@@ -1280,7 +1313,8 @@ testListProvisionedUsers = do
   (tok, _) <- registerIdPAndScimToken
   listUsers_ (Just tok) Nothing spar !!! do
     const 400 === statusCode
-    const (Just "tooMany") =~= responseBody
+    mkScimErrorResp Nothing (Just "tooMany") "400"
+      === responseBody
 
 testFindProvisionedUser :: TestSpar ()
 testFindProvisionedUser = do
@@ -1572,8 +1606,10 @@ testGetNoDeletedUsers = do
   -- Delete the user
   call $ deleteUserOnBrig (env ^. teBrig) userid
   -- Try to find the user
-  getUser_ (Just tok) userid (env ^. teSpar)
-    !!! const 404 === statusCode
+  getUser_ (Just tok) userid (env ^. teSpar) !!! do
+    const 404 === statusCode
+    mkScimErrorResp Nothing Nothing "404"
+      === responseBody
   -- TODO(arianvp): What does this mean; @fisx ??
   pendingWith "TODO: delete via SCIM"
 
@@ -1586,8 +1622,13 @@ testUserGetFailsWithNotFoundIfOutsideTeam = do
   (tokTeamB, _) <- registerIdPAndScimToken
   storedUser <- createUser tokTeamA user
   let userid = scimUserId storedUser
-  getUser_ (Just tokTeamB) userid (env ^. teSpar)
-    !!! const 404 === statusCode
+  getUser_ (Just tokTeamB) userid (env ^. teSpar) !!! do
+    const 404 === statusCode
+    mkScimErrorResp
+      (Just ("User " <> idToText userid <> " not found"))
+      Nothing
+      "404"
+      === responseBody
 
 {- does not find a non-scim-provisioned user:
 
@@ -1694,8 +1735,10 @@ testUserUpdateFailsWithNotFoundIfOutsideTeam = do
   let userid = scimUserId storedUser
   -- Overwrite the user with another randomly-generated user
   user' <- randomScimUser
-  updateUser_ (Just tokTeamB) (Just userid) user' (env ^. teSpar)
-    !!! const 404 === statusCode
+  updateUser_ (Just tokTeamB) (Just userid) user' (env ^. teSpar) !!! do
+    const 404 === statusCode
+    mkScimErrorResp Nothing Nothing "404"
+      === responseBody
 
 -- | Test that @PUT@-ting the user and then @GET@-ting it returns the right thing.
 testScimSideIsUpdated :: TestSpar ()
@@ -1747,8 +1790,10 @@ testUpdateToExistingExternalIdFails = do
   env <- ask
   -- Should fail with 409 to denote that the given externalId is in use by a
   -- different user.
-  updateUser_ (Just tok) (Just $ scimUserId storedNewUser) updatedNewUser (env ^. teSpar)
-    !!! const 409 === statusCode
+  updateUser_ (Just tok) (Just $ scimUserId storedNewUser) updatedNewUser (env ^. teSpar) !!! do
+    const 409 === statusCode
+    mkScimErrorResp Nothing (Just "uniqueness") "409"
+      === responseBody
 
 -- | Test that updating still works when name and handle are not changed.
 --
@@ -2017,6 +2062,7 @@ specPatchUser = do
             PatchOp.Remove
             (Just (PatchOp.NormalPath (Filter.topLevelAttrPath name)))
             Nothing
+
     it "doing nothing doesn't change the user" $ do
       (tok, _) <- registerIdPAndScimToken
       user <- randomScimUser
@@ -2024,6 +2070,7 @@ specPatchUser = do
       let userid = scimUserId storedUser
       storedUser' <- patchUser tok userid (PatchOp.PatchOp [])
       liftIO $ storedUser `shouldBe` storedUser'
+
     it "can update userName" $ do
       (tok, _) <- registerIdPAndScimToken
       user <- randomScimUser
@@ -2037,6 +2084,7 @@ specPatchUser = do
             [replaceAttrib "userName" userName]
       let user'' = Scim.value (Scim.thing storedUser')
       liftIO $ Scim.User.userName user'' `shouldBe` userName
+
     it "can't update to someone else's userName" $ do
       env <- ask
       (tok, _) <- registerIdPAndScimToken
@@ -2046,7 +2094,11 @@ specPatchUser = do
       let userid = scimUserId storedUser
       _ <- createUser tok user'
       let patchOp = PatchOp.PatchOp [replaceAttrib "userName" (Scim.User.userName user')]
-      patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! const 409 === statusCode
+      patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! do
+        const 409 === statusCode
+        mkScimErrorResp Nothing (Just "uniqueness") "409"
+          === responseBody
+
     it "can't update to someone else's externalId" $ do
       env <- ask
       (tok, _) <- registerIdPAndScimToken
@@ -2056,13 +2108,21 @@ specPatchUser = do
       let userid = scimUserId storedUser
       _ <- createUser tok user'
       let patchOp = PatchOp.PatchOp [replaceAttrib "externalId" (Scim.User.externalId user')]
-      patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! const 409 === statusCode
+      patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! do
+        const 409 === statusCode
+        mkScimErrorResp Nothing (Just "uniqueness") "409"
+          === responseBody
+
     it "can't update a non-existing user" $ do
       env <- ask
       (tok, _) <- registerIdPAndScimToken
       userid <- liftIO $ randomId
       let patchOp = PatchOp.PatchOp [replaceAttrib "externalId" ("blah" :: Text)]
-      patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! const 404 === statusCode
+      patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! do
+        const 404 === statusCode
+        mkScimErrorResp Nothing Nothing "404"
+          === responseBody
+
     it "can update displayName" $ do
       (tok, _) <- registerIdPAndScimToken
       user <- randomScimUser
@@ -2076,6 +2136,7 @@ specPatchUser = do
             [replaceAttrib "displayName" displayName]
       let user'' = Scim.value (Scim.thing storedUser')
       liftIO $ Scim.User.displayName user'' `shouldBe` displayName
+
     it "can update externalId" $ do
       (tok, _) <- registerIdPAndScimToken
       user <- randomScimUser
@@ -2089,10 +2150,15 @@ specPatchUser = do
             [replaceAttrib "externalId" externalId]
       let user'' = Scim.value . Scim.thing $ storedUser'
       liftIO $ Scim.User.externalId user'' `shouldBe` externalId
+
     it "replace role works" $ testPatchRole replaceAttrib
+
     it "add role works" $ testPatchRole addAttrib
+
     it "replace with invalid input should fail" $ testPatchIvalidInput replaceAttrib
+
     it "add with invalid input should fail" $ testPatchIvalidInput addAttrib
+
     it "replacing every supported atttribute at once works" $ do
       (tok, _) <- registerIdPAndScimToken
       user <- randomScimUser
@@ -2113,6 +2179,7 @@ specPatchUser = do
       liftIO $ Scim.User.externalId user'' `shouldBe` externalId
       liftIO $ Scim.User.userName user'' `shouldBe` userName
       liftIO $ Scim.User.displayName user'' `shouldBe` displayName
+
     it "other valid attributes that we do not explicit support throw an error" $ do
       (tok, _) <- registerIdPAndScimToken
       user <- randomScimUser
@@ -2120,8 +2187,11 @@ specPatchUser = do
       let userid = scimUserId storedUser
       env <- ask
       let patchOp = PatchOp.PatchOp [replaceAttrib "emails" ("hello" :: Text)]
-      patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar)
-        !!! const 400 === statusCode
+      patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! do
+        const 400 === statusCode
+        mkScimErrorResp Nothing (Just "invalidPath") "400"
+          === responseBody
+
     it "invalid attributes are quietly ignored for now" $ do
       (tok, _) <- registerIdPAndScimToken
       user <- randomScimUser
@@ -2129,8 +2199,11 @@ specPatchUser = do
       let userid = scimUserId storedUser
       env <- ask
       let patchOp = PatchOp.PatchOp [replaceAttrib "totallyBogus" ("hello" :: Text)]
-      patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar)
-        !!! const 400 === statusCode
+      patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! do
+        const 400 === statusCode
+        mkScimErrorResp Nothing (Just "invalidPath") "400"
+          === responseBody
+
     -- NOTE: Remove at the moment actually never works! As all the fields
     -- we support are required in our book
     it "userName cannot be removed according to scim" $ do
@@ -2140,7 +2213,11 @@ specPatchUser = do
       storedUser <- createUser tok user
       let userid = scimUserId storedUser
       let patchOp = PatchOp.PatchOp [removeAttrib "userName"]
-      patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! const 400 === statusCode
+      patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! do
+        const 400 === statusCode
+        mkScimErrorResp Nothing (Just "mutability") "400"
+          === responseBody
+
     it "displayName cannot be removed in spar (though possible in scim). Diplayname is required in Wire" $ do
       pendingWith
         "We default to the externalId when displayName is removed. lets keep that for now"
@@ -2158,7 +2235,10 @@ specPatchUser = do
       storedUser <- createUser tok user
       let userid = scimUserId storedUser
       let patchOp = PatchOp.PatchOp [removeAttrib "externalId"]
-      patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! const 400 === statusCode
+      patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! do
+        const 400 === statusCode
+        mkScimErrorResp Nothing (Just "invalidValue") "400"
+          === responseBody
 
 testPatchIvalidInput :: (Text -> [Role] -> Operation) -> TestSpar ()
 testPatchIvalidInput patchOp = do
@@ -2172,14 +2252,22 @@ testPatchIvalidInput patchOp = do
         PatchOp.Operation
           PatchOp.Replace
           (Just (PatchOp.NormalPath (Filter.topLevelAttrPath "roles")))
-          (Just $ Data.Aeson.Array $ V.singleton $ Data.Aeson.String "invalid-role")
+          (Just $ Aeson.Array $ V.singleton $ Aeson.String "invalid-role")
   patchUser' tok uid (PatchOp.PatchOp [patchWithInvalidRole]) !!! do
     const 400 === statusCode
-    const (Just "The role 'invalid-role' is not valid. Valid roles are owner, admin, member, partner.") =~= responseBody
+    mkScimErrorResp
+      (Just "The role 'invalid-role' is not valid. Valid roles are owner, admin, member, partner. (put)")
+      (Just "invalidValue")
+      "400"
+      === responseBody
   let patchWithTooManyRoles = patchOp "roles" [defaultRole, defaultRole]
   patchUser' tok uid (PatchOp.PatchOp [patchWithTooManyRoles]) !!! do
     const 400 === statusCode
-    const (Just "A user cannot have more than one role.") =~= responseBody
+    mkScimErrorResp
+      (Just "A user cannot have more than one role. (put)")
+      (Just "invalidValue")
+      "400"
+      === responseBody
 
 testPatchRole :: (Text -> [Role] -> Operation) -> TestSpar ()
 testPatchRole replaceOrAdd = do
@@ -2233,8 +2321,8 @@ specDeleteUser = do
     it "responds with 405 (just making sure...)" $ do
       env <- ask
       (tok, _) <- registerIdPAndScimToken
-      deleteUser_ (Just tok) Nothing (env ^. teSpar)
-        !!! const 405 === statusCode
+      deleteUser_ (Just tok) Nothing (env ^. teSpar) !!! do
+        const 405 === statusCode
   describe "DELETE /Users/:id" $ do
     it "should delete user from brig, spar.scim_user_times, spar.user" $ do
       (tok, (_, _, idp)) <- registerIdPAndScimToken
@@ -2292,8 +2380,10 @@ specDeleteUser = do
       storedUser <- createUser tok user
       spar <- view teSpar
       let uid = scimUserId storedUser
-      deleteUser_ Nothing (Just uid) spar
-        !!! const 401 === statusCode
+      deleteUser_ Nothing (Just uid) spar !!! do
+        const 401 === statusCode
+        mkScimErrorResp Nothing Nothing "401"
+          === responseBody
     it "should always pretend to succeed, even if user exists in other team (does not leak information by diverging behavior)" $ do
       (tok, _) <- registerIdPAndScimToken
       user <- randomScimUser
@@ -2313,8 +2403,10 @@ specDeleteUser = do
       let uid = scimUserId storedUser
       deleteUser_ (Just tok) (Just uid) spar
         !!! const 204 === statusCode
-      aFewTimes (getUser_ (Just tok) uid spar) ((== 404) . statusCode)
-        !!! const 404 === statusCode
+      aFewTimes (getUser_ (Just tok) uid spar) ((== 404) . statusCode) !!! do
+        const 404 === statusCode
+        mkScimErrorResp Nothing Nothing "404"
+          === responseBody
       deleteUser_ (Just tok) (Just uid) spar
         !!! const 204 === statusCode
     it "whether implemented or not, does *NOT EVER* respond with 5xx!" $ do
@@ -2348,8 +2440,10 @@ specDeleteUser = do
 
         deleteUser_ (Just tok) (Just uid) spar
           !!! const 204 === statusCode
-        aFewTimes (getUser_ (Just tok) uid spar) ((== 404) . statusCode)
-          !!! const 404 === statusCode
+        aFewTimes (getUser_ (Just tok) uid spar) ((== 404) . statusCode) !!! do
+          const 404 === statusCode
+          mkScimErrorResp Nothing Nothing "404"
+            === responseBody
 
     context "user not touched via scim before" $ do
       it "works" $ do
@@ -2368,8 +2462,10 @@ specDeleteUser = do
           !!! const 200 === statusCode
         deleteUser_ (Just tok) (Just uid) spar
           !!! const 204 === statusCode
-        aFewTimes (getUser_ (Just tok) uid spar) ((== 404) . statusCode)
-          !!! const 404 === statusCode
+        aFewTimes (getUser_ (Just tok) uid spar) ((== 404) . statusCode) !!! do
+          const 404 === statusCode
+          mkScimErrorResp Nothing Nothing "404"
+            === responseBody
 
       context "No IDP" $ do
         describe "Deleting a User" $ do
@@ -2494,27 +2590,39 @@ specSCIMManaged = do
         resp <- call $ post (brig . path "/access" . forceCookie cky)  resp -> Maybe LByteString
+          expectedResponseBody updatedThing =
+            -- NB: this is for requests to brig, not scim requests, so
+            -- the code-label-message schema is legit!
+            mkBrigErrorResp
+              [aesonQQ|
+                {
+                  "code": 403,
+                  "label": "managed-by-scim",
+                  "message": #{"Updating " <> updatedThing <> " is not allowed, because it is managed by SCIM, or E2EId is enabled"}
+                }|]
       do
         newEmail <- randomEmail
         call $
           changeEmailBrigCreds brig cky sessiontok newEmail !!! do
-            (fmap Wai.label . responseJsonEither @Wai.Error) === const (Right "managed-by-scim")
             statusCode === const 403
+            expectedResponseBody "email" === responseBody
 
       do
         handleTxt <- randomAlphaNum
         call $
           changeHandleBrig brig uid handleTxt !!! do
-            (fmap Wai.label . responseJsonEither @Wai.Error) === const (Right "managed-by-scim")
             statusCode === const 403
+            expectedResponseBody "handle" === responseBody
 
       do
         displayName <- Name <$> randomAlphaNum
         let uupd = UserUpdate (Just displayName) Nothing Nothing Nothing Nothing
         call $
           updateProfileBrig brig uid uupd !!! do
-            (fmap Wai.label . responseJsonEither @Wai.Error) === const (Right "managed-by-scim")
             statusCode === const 403
+            expectedResponseBody "name" === responseBody
+
     it "created_on should be filled in CSV export" $ do
       g <- view teGalley
       user <- randomScimUser
@@ -2581,3 +2689,8 @@ executeTeamUserSearch brig teamid self mbSearchText =
       >= fmap Search.searchResults . responseJsonError
+
+-- | Assert the exact body of an error response from an endpoint that is /not/ scim: brig has
+-- its own code-label-message error schema.  For scim errors use 'mkScimErrorResp'.
+mkBrigErrorResp :: Aeson.Value -> resp -> Maybe LByteString
+mkBrigErrorResp val _ = Just (Aeson.encode val)
diff --git a/services/spar/test-integration/Util/Scim.hs b/services/spar/test-integration/Util/Scim.hs
index 0599fa64039..aee2bf2aa75 100644
--- a/services/spar/test-integration/Util/Scim.hs
+++ b/services/spar/test-integration/Util/Scim.hs
@@ -25,6 +25,8 @@ import Bilge
 import Bilge.Assert
 import Control.Lens
 import Control.Monad.Random
+import qualified Data.Aeson as Aeson
+import Data.Aeson.Lens (key, _String)
 import Data.ByteString.Conversion
 import qualified Data.ByteString.Lazy as Lazy
 import Data.Handle (Handle, parseHandle)
@@ -766,3 +768,35 @@ checkTeamMembersRole :: (HasCallStack) => TeamId -> UserId -> UserId -> Role ->
 checkTeamMembersRole tid owner uid role = do
   [member] <- filter ((== uid) . (^. Member.userId)) <$> getTeamMembers owner tid
   liftIO $ (member ^. Member.permissions . to Member.permissionsRole) `shouldBe` Just role
+
+-- | Create the body of a scim error response (rfc7644, section 3.12).  The arguments are the
+-- @detail@, @scimType@ and @status@ fields; @schemas@ is the same for every scim error and is
+-- filled in here.
+--
+-- Both 'Maybe' arguments mean different things when they are 'Nothing':
+--
+--   * @detail@: the message is /not tested/.  Whatever the response contains is echoed into the
+--     expected value, so this field can never make the comparison fail.
+--
+--   * @scimType@: the key must be /absent/.  That is what the server does when there is no error
+--     type, as opposed to rendering it as @null@.
+--
+-- > deleteUser_ Nothing (Just uid) spar !!! do
+-- >   const 401 === statusCode
+-- >   mkScimErrorResp Nothing Nothing "401" === responseBody
+mkScimErrorResp ::
+  Maybe Text ->
+  Maybe Text ->
+  Text ->
+  Response (Maybe LByteString) ->
+  Maybe LByteString
+mkScimErrorResp mDetail scimType status resp =
+  Just . Aeson.encode . Aeson.object . catMaybes $
+    [ ("detail" Aeson..=) <$> (mDetail <|> actualDetail),
+      Just ("schemas" Aeson..= ["urn:ietf:params:scim:api:messages:2.0:Error" :: Text]),
+      ("scimType" Aeson..=) <$> scimType,
+      Just ("status" Aeson..= status)
+    ]
+  where
+    actualDetail :: Maybe Text
+    actualDetail = responseBody resp >>= (^? key "detail" . _String)
diff --git a/services/spar/test/Test/Spar/ErrorSpec.hs b/services/spar/test/Test/Spar/ErrorSpec.hs
new file mode 100644
index 00000000000..ebfba25b443
--- /dev/null
+++ b/services/spar/test/Test/Spar/ErrorSpec.hs
@@ -0,0 +1,55 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Test.Spar.ErrorSpec where
+
+import Data.Aeson (eitherDecode')
+import Data.Aeson.QQ (aesonQQ)
+import Imports
+import qualified SAML2.WebSSO as SAML
+import Servant (ServerError (..))
+import Spar.Error
+import Test.Hspec
+import qualified Web.Scim.Schema.Error as Scim
+
+spec :: Spec
+spec = describe "sparToServerError" $ do
+  -- RFC 7644 section 3.12 requires that the response body of a SCIM error *is*
+  -- the SCIM error object, not a wire-server 'Wai.Error' with the SCIM error
+  -- object nested (double-encoded) into its 'message' field.
+  it "renders a SCIM error as the bare RFC 7644 error object" $ do
+    let scimErr =
+          Scim.badRequest
+            Scim.InvalidValue
+            (Just "Could not process externalId.")
+        serverErr = sparToServerError (SAML.CustomError (SparScimError scimErr))
+    eitherDecode' (errBody serverErr)
+      `shouldBe` Right
+        [aesonQQ|
+                  {
+                    "detail": "Could not process externalId.",
+                    "schemas": [
+                      "urn:ietf:params:scim:api:messages:2.0:Error"
+                    ],
+                    "scimType": "invalidValue",
+                    "status": "400"
+                  }|]
+    errHTTPCode serverErr `shouldBe` 400
+    lookup "Content-Type" (errHeaders serverErr)
+      `shouldBe` Just "application/scim+json;charset=utf-8"

From bbcbface82ea8471dcc6cf85baca8a406425b30a Mon Sep 17 00:00:00 2001
From: Matthias Fischmann 
Date: Tue, 18 Aug 2026 09:47:04 +0200
Subject: [PATCH 088/113] Fix integration test cleanup on federation instance
 V2. (#5447)

---
 ...integration-test-cleanup-on-federation-instance-v2 |  1 +
 charts/integration/templates/configmap.yaml           |  7 +++++++
 integration/test/Testlib/Env.hs                       |  1 +
 integration/test/Testlib/Run.hs                       | 11 ++++++++---
 integration/test/Testlib/Types.hs                     |  3 +++
 services/integration.yaml                             |  7 +++++++
 6 files changed, 27 insertions(+), 3 deletions(-)
 create mode 100644 changelog.d/5-internal/WPB---fix-integration-test-cleanup-on-federation-instance-v2

diff --git a/changelog.d/5-internal/WPB---fix-integration-test-cleanup-on-federation-instance-v2 b/changelog.d/5-internal/WPB---fix-integration-test-cleanup-on-federation-instance-v2
new file mode 100644
index 00000000000..3cd02e782f8
--- /dev/null
+++ b/changelog.d/5-internal/WPB---fix-integration-test-cleanup-on-federation-instance-v2
@@ -0,0 +1 @@
+Fix integration test cleanup on federation instance V2.
diff --git a/charts/integration/templates/configmap.yaml b/charts/integration/templates/configmap.yaml
index a1b2592c310..02a9d4db410 100644
--- a/charts/integration/templates/configmap.yaml
+++ b/charts/integration/templates/configmap.yaml
@@ -102,6 +102,13 @@ data:
       adminPort: 15672
       vHost: /
 
+    rabbitmq-v2:
+      host: rabbitmq.wire-federation-v2.svc.cluster.local
+      port: 5671
+      adminHost: rabbitmq.wire-federation-v2.svc.cluster.local
+      adminPort: 15672
+      vHost: /
+
     backendTwo:
 
       brig:
diff --git a/integration/test/Testlib/Env.hs b/integration/test/Testlib/Env.hs
index 2ace4753b82..cdd9f7e9250 100644
--- a/integration/test/Testlib/Env.hs
+++ b/integration/test/Testlib/Env.hs
@@ -140,6 +140,7 @@ mkGlobalEnv cfgFile = do
         gRabbitMQConfig = intConfig.rabbitmq,
         gRabbitMQConfigV0 = intConfig.rabbitmqV0,
         gRabbitMQConfigV1 = intConfig.rabbitmqV1,
+        gRabbitMQConfigV2 = intConfig.rabbitmqV2,
         gTempDir = tempDir,
         gTimeOutSeconds = timeOutSeconds,
         gDNSMockServerConfig = intConfig.dnsMockServer,
diff --git a/integration/test/Testlib/Run.hs b/integration/test/Testlib/Run.hs
index 5c0cd14540b..e122e2c4dd9 100644
--- a/integration/test/Testlib/Run.hs
+++ b/integration/test/Testlib/Run.hs
@@ -177,7 +177,7 @@ runTests tests mXMLOutput cfg = do
             pure (TestSuiteReport [TestCaseReport qname TestSuccess tm])
       writeChan output Nothing
       wait displayThread
-      deleteFederationV0AndV1Queues genv
+      deleteFederationVQueues genv
       printReport report
       mapM_ (saveXMLReport report) mXMLOutput
       when (any (\testCase -> testCase.result /= TestSuccess) report.cases) $
@@ -216,8 +216,8 @@ runMigrations = do
       (_, _, _, ph) <- liftIO $ createProcess cp
       void $ liftIO $ waitForProcess ph
 
-deleteFederationV0AndV1Queues :: GlobalEnv -> IO ()
-deleteFederationV0AndV1Queues env = do
+deleteFederationVQueues :: GlobalEnv -> IO ()
+deleteFederationVQueues env = do
   let testDomains = env.gDomain1 : env.gDomain2 : env.gDynamicDomains
   putStrLn "Attempting to delete federation V0 queues..."
   (mV0User, mV0Pass) <- readCredsFromEnvWithSuffix "V0"
@@ -228,6 +228,11 @@ deleteFederationV0AndV1Queues env = do
   (mV1User, mV1Pass) <- readCredsFromEnvWithSuffix "V1"
   fromMaybe (putStrLn "No or incomplete credentials for fed V1 RabbitMQ") $
     deleteFederationQueues testDomains env.gRabbitMQConfigV1 <$> mV1User <*> mV1Pass
+
+  putStrLn "Attempting to delete federation V2 queues..."
+  (mV2User, mV2Pass) <- readCredsFromEnvWithSuffix "V2"
+  fromMaybe (putStrLn "No or incomplete credentials for fed V2 RabbitMQ") $
+    deleteFederationQueues testDomains env.gRabbitMQConfigV2 <$> mV2User <*> mV2Pass
   where
     readCredsFromEnvWithSuffix :: String -> IO (Maybe Text, Maybe Text)
     readCredsFromEnvWithSuffix suffix =
diff --git a/integration/test/Testlib/Types.hs b/integration/test/Testlib/Types.hs
index cdb3d60caa1..638c6a65279 100644
--- a/integration/test/Testlib/Types.hs
+++ b/integration/test/Testlib/Types.hs
@@ -141,6 +141,7 @@ data GlobalEnv = GlobalEnv
     gRabbitMQConfig :: RabbitMqAdminOpts,
     gRabbitMQConfigV0 :: RabbitMqAdminOpts,
     gRabbitMQConfigV1 :: RabbitMqAdminOpts,
+    gRabbitMQConfigV2 :: RabbitMqAdminOpts,
     gTempDir :: FilePath,
     gTimeOutSeconds :: Int,
     gDNSMockServerConfig :: DNSMockServerConfig,
@@ -160,6 +161,7 @@ data IntegrationConfig = IntegrationConfig
     rabbitmq :: RabbitMqAdminOpts,
     rabbitmqV0 :: RabbitMqAdminOpts,
     rabbitmqV1 :: RabbitMqAdminOpts,
+    rabbitmqV2 :: RabbitMqAdminOpts,
     cassandra :: CassandraConfig,
     dnsMockServer :: DNSMockServerConfig,
     cellsEventQueue :: String
@@ -180,6 +182,7 @@ instance FromJSON IntegrationConfig where
         <*> o .: fromString "rabbitmq"
         <*> o .: fromString "rabbitmq-v0"
         <*> o .: fromString "rabbitmq-v1"
+        <*> o .: fromString "rabbitmq-v2"
         <*> o .: fromString "cassandra"
         <*> o .: fromString "dnsMockServer"
         <*> o .: fromString "cellsEventQueue"
diff --git a/services/integration.yaml b/services/integration.yaml
index 427aa761d1e..2da7e194e1f 100644
--- a/services/integration.yaml
+++ b/services/integration.yaml
@@ -187,6 +187,13 @@ rabbitmq-v1:
   adminPort: 15672
   vHost: federation-v1
 
+rabbitmq-v2:
+  host: localhost
+  port: 5671
+  adminHost: localhost
+  adminPort: 15672
+  vHost: federation-v2
+
 cassandra:
   host: 127.0.0.1
   port: 9042

From 2c8ad5cb8d25ebdc2bcbc0270ddb06b38f087938 Mon Sep 17 00:00:00 2001
From: Matthias Fischmann 
Date: Tue, 18 Aug 2026 09:48:45 +0200
Subject: [PATCH 089/113] Allow team admin to remove bot from all
 conversations. (#5450)

---
 ...rom-all-conversations_-instead-of-crashing |  1 +
 services/brig/src/Brig/Provider/API.hs        |  9 ++-
 .../brig/test/integration/API/Provider.hs     | 69 ++++++++++++++++++-
 3 files changed, 75 insertions(+), 4 deletions(-)
 create mode 100644 changelog.d/3-bug-fixes/WPB-28083-allow-team-admin-to-remove-bot-from-all-conversations_-instead-of-crashing

diff --git a/changelog.d/3-bug-fixes/WPB-28083-allow-team-admin-to-remove-bot-from-all-conversations_-instead-of-crashing b/changelog.d/3-bug-fixes/WPB-28083-allow-team-admin-to-remove-bot-from-all-conversations_-instead-of-crashing
new file mode 100644
index 00000000000..e9dc36e8864
--- /dev/null
+++ b/changelog.d/3-bug-fixes/WPB-28083-allow-team-admin-to-remove-bot-from-all-conversations_-instead-of-crashing
@@ -0,0 +1 @@
+Allow team admin to remove bot from all conversations, instead of crashing.  (This is how it's already done in 'finishDeleteService'.)
diff --git a/services/brig/src/Brig/Provider/API.hs b/services/brig/src/Brig/Provider/API.hs
index ae666e1f2b0..ace4335a31f 100644
--- a/services/brig/src/Brig/Provider/API.hs
+++ b/services/brig/src/Brig/Provider/API.hs
@@ -710,7 +710,7 @@ updateServiceWhitelist ::
   TeamId ->
   Public.UpdateServiceWhitelist ->
   (Handler r) UpdateServiceWhitelistResp
-updateServiceWhitelist uid con tid upd = do
+updateServiceWhitelist uid _con tid upd = do
   -- Preconditions
   guardSecondFactorDisabled (Just uid)
   let pid = updateServiceWhitelistProvider upd
@@ -735,7 +735,12 @@ updateServiceWhitelist uid con tid upd = do
           .| C.mapM_
             ( unsafePooledMapConcurrentlyN_
                 16
-                (uncurry (deleteBot uid (Just con)))
+                ( \(bid, cid) ->
+                    -- Each bot removes itself: the team admin de-whitelisting
+                    -- the service is not necessarily allowed to remove members in the
+                    -- conversations the bots are in.
+                    deleteBot (botUserId bid) Nothing bid cid
+                )
             )
       wrapClientE $ DB.deleteServiceWhitelist (Just tid) pid sid
       pure UpdateServiceWhitelistRespChanged
diff --git a/services/brig/test/integration/API/Provider.hs b/services/brig/test/integration/API/Provider.hs
index 177e0a90bd8..732558e1c17 100644
--- a/services/brig/test/integration/API/Provider.hs
+++ b/services/brig/test/integration/API/Provider.hs
@@ -142,6 +142,10 @@ tests dom brigOpts conf p db b c g n = do
               testSearchWhitelistHonorUpdates conf db b,
             test p "de-whitelisted bots are removed" $
               testWhitelistKickout dom conf db b g c,
+            test p "de-whitelisted bots are removed from conversations the team admin does not administer" $
+              testWhitelistKickoutNotConvAdmin True dom conf db b g c,
+            test p "de-whitelisted bots are removed from conversations the team admin is not in" $
+              testWhitelistKickoutNotConvAdmin False dom conf db b g c,
             test p "de-whitelisting works with deleted conversations" $
               testDeWhitelistDeletedConv conf db b g c,
             test p "whitelist via nginz" $ testWhitelistNginz conf db b n
@@ -897,8 +901,9 @@ testWhitelistKickout localDomain config db brig galley cannon = do
       _ <- waitFor (2 # Second) not (isMember galley lbuid cid)
       getBotConv galley bid cid
         !!! const 404 === statusCode
-      wsAssertMemberLeave ws qcid qowner [tUntagged lbuid]
-      svcAssertMemberLeave buf qowner [tUntagged lbuid] qcid
+      -- The bot removes itself, see 'updateServiceWhitelist'
+      wsAssertMemberLeave ws qcid (tUntagged lbuid) [tUntagged lbuid]
+      svcAssertMemberLeave buf (tUntagged lbuid) [tUntagged lbuid] qcid
     -- The bot should not get any further events
     liftIO $
       timeout (2 # Second) (readChan buf) >>= \case
@@ -906,6 +911,66 @@ testWhitelistKickout localDomain config db brig galley cannon = do
         Just (TestBotCreated _) -> assertFailure "bot got a TestBotCreated event"
         Just (TestBotMessage e) -> assertFailure ("bot got an event: " <> show (evtType e))
 
+-- | The team admin who de-whitelists a service is not necessarily allowed to
+-- remove members from the conversations the service's bots are in. The bots have to
+-- be kicked out either way.
+testWhitelistKickoutNotConvAdmin ::
+  -- | Whether the team admin is a (non-admin) member of the bot's conversation
+  Bool ->
+  Domain ->
+  Config ->
+  DB.ClientState ->
+  Brig ->
+  Galley ->
+  Cannon ->
+  Http ()
+testWhitelistKickoutNotConvAdmin ownerInConv localDomain config db brig galley cannon = do
+  -- Create a team with an owner and a member
+  (owner, tid) <- Team.createUserWithTeam brig
+  member <- Team.createTeamMember brig galley owner tid fullPermissions
+  let memberId = userId member
+      qmemberId = userQualifiedId member
+      lowner = toLocalUnsafe localDomain owner
+  -- The member creates the conversation and is therefore its only conversation
+  -- admin; the owner joins as a plain conversation member, or not at all.
+  cid <- Team.createTeamConvWithRole roleNameWireMember galley tid memberId [owner | ownerInConv] Nothing
+  let qcid = Qualified cid localDomain
+  -- Create a service
+  withTestService config db brig defServiceApp $ \sref buf -> do
+    -- Add it to the conversation
+    let pid = sref ^. serviceRefProvider
+        sid = sref ^. serviceRefId
+    whitelistService brig owner tid pid sid
+    bot <-
+      responseJsonError
+        =<< (addBot brig memberId pid sid cid  do
+      dewhitelistService brig owner tid pid sid
+      _ <- waitFor (2 # Second) not (isMember galley lbuid cid)
+      getBotConv galley bid cid
+        !!! const 404 === statusCode
+      wsAssertMemberLeave ws qcid (tUntagged lbuid) [tUntagged lbuid]
+      svcAssertMemberLeave buf (tUntagged lbuid) [tUntagged lbuid] qcid
+    -- The bot is gone from the member list the users see, too
+    let assertBotGone :: UserId -> Http ()
+        assertBotGone u = do
+          mems <-
+            fmap cnvMembers . responseJsonError @_ @(OwnConversation GroupConvType)
+              =<< (getConversationQualified galley u qcid  DB.ClientState -> Brig -> Galley -> Cannon -> Http ()
 testDeWhitelistDeletedConv config db brig galley cannon = do
   -- Create a service

From 3453888e7b3e494cf1471ca241749b1bd1271561 Mon Sep 17 00:00:00 2001
From: Sven Tennie 
Date: Tue, 18 Aug 2026 14:50:47 +0200
Subject: [PATCH 090/113] cannon: log unavailable gundeck (#5454)

Instead of silently closing the WebSocket, log that the call to Gundeck
failed. This helps operators to debug related issues (by pointing them
to Gundeck's logs).
---
 .../cannon-log-register-remote-presence-failure     |  4 ++++
 services/cannon/src/Cannon/App.hs                   | 13 ++++++++++++-
 2 files changed, 16 insertions(+), 1 deletion(-)
 create mode 100644 changelog.d/5-internal/cannon-log-register-remote-presence-failure

diff --git a/changelog.d/5-internal/cannon-log-register-remote-presence-failure b/changelog.d/5-internal/cannon-log-register-remote-presence-failure
new file mode 100644
index 00000000000..9ebbac9c3c3
--- /dev/null
+++ b/changelog.d/5-internal/cannon-log-register-remote-presence-failure
@@ -0,0 +1,4 @@
+Cannon now logs an error when registering a client's remote presence with
+Gundeck fails, so operators can tell this apart from an actual
+websocket/network issue (e.g. `PongTimeout` caused by Gundeck losing its Redis
+connection).
diff --git a/services/cannon/src/Cannon/App.hs b/services/cannon/src/Cannon/App.hs
index a0c4ff3d88b..538e9b14acd 100644
--- a/services/cannon/src/Cannon/App.hs
+++ b/services/cannon/src/Cannon/App.hs
@@ -48,11 +48,22 @@ wsapp k c e pc = runWS e (go `catches` ioErrors k c)
         ws <- mkWebSocket conn
         debug $ client (key2bytes k) ~~ "websocket" .= connIdent ws
         registerLocal k ws
-        registerRemote k c `onException` (unregisterLocal k ws >> close k ws)
+        registerRemote k c
+          `onException` ( logRegisterRemoteError ws
+                            >> unregisterLocal k ws
+                            >> close k ws
+                        )
         timeout (maxLifetime * 1_000_000) (continue ws k) `finally` terminate k ws >>= \case
           Nothing ->
             Logger.info $ msg (val "websocket reached max lifetime") . client (key2bytes k)
           Just () -> pure ()
+    logRegisterRemoteError ws =
+      Logger.err $
+        logKey k
+          . client (key2bytes k)
+          ~~ "websocket"
+          .= connIdent ws
+          ~~ msg (val "Registering remote presence at Gundeck failed. Check Gundeck.")
 
 continue :: (MonadLogger m, MonadUnliftIO m) => Websocket -> Key -> m ()
 continue ws k = do

From 635f383f0d36fee35198fb212f9e39a06e7b2d8b Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Tue, 18 Aug 2026 17:17:52 +0200
Subject: [PATCH 091/113] WPB-27912: Deprecate backgroundEffects feature flag
 at API v17 (#5431)

---
 .../WPB-27912-background-effects              |  9 +++++
 .../WPB-27912-background-effects-endpoint     |  7 ++++
 .../templates/galley/configmap.yaml           |  4 --
 charts/wire-server/values.yaml                |  4 --
 .../src/developer/reference/config-options.md | 23 +++++------
 integration/test/Test/FeatureFlags.hs         |  2 +-
 .../Test/FeatureFlags/BackgroundEffects.hs    | 39 ++++++++++++++++++-
 integration/test/Test/FeatureFlags/Util.hs    |  2 +-
 .../src/Wire/API/Routes/Internal/Galley.hs    |  2 +-
 .../Wire/API/Routes/Public/Galley/Feature.hs  |  2 +-
 libs/wire-api/src/Wire/API/Team/Feature.hs    |  9 +++--
 .../galley/src/Galley/API/Public/Feature.hs   |  2 +-
 12 files changed, 76 insertions(+), 29 deletions(-)
 create mode 100644 changelog.d/0-release-notes/WPB-27912-background-effects
 create mode 100644 changelog.d/1-api-changes/WPB-27912-background-effects-endpoint

diff --git a/changelog.d/0-release-notes/WPB-27912-background-effects b/changelog.d/0-release-notes/WPB-27912-background-effects
new file mode 100644
index 00000000000..379491a3272
--- /dev/null
+++ b/changelog.d/0-release-notes/WPB-27912-background-effects
@@ -0,0 +1,9 @@
+* The `backgroundEffects` team feature flag is **deprecated** (WPB-27912). Its
+  default is now **enabled and locked**, and the Helm configuration override for
+  `backgroundEffects` has been removed from `charts/wire-server`. The flag's
+  data type and its public/internal HTTP endpoints are retained for backward
+  compatibility; any Helm overrides for `backgroundEffects` are now ignored and
+  can be removed. The public/internal HTTP endpoints return 404 at API version
+  v17 and remain available through v16; the flag type remains deprecated. The
+  aggregate `GET /feature-configs` and `GET /teams/:tid/features` endpoints
+  continue to include `backgroundEffects` at all API versions, including v17.
diff --git a/changelog.d/1-api-changes/WPB-27912-background-effects-endpoint b/changelog.d/1-api-changes/WPB-27912-background-effects-endpoint
new file mode 100644
index 00000000000..e7c75238edd
--- /dev/null
+++ b/changelog.d/1-api-changes/WPB-27912-background-effects-endpoint
@@ -0,0 +1,7 @@
+The `backgroundEffects` team feature endpoints are deprecated and return 404 for
+clients on API version v17: the public `GET`/`PUT /teams/:tid/features/backgroundEffects`
+and the internal legacy lock `PUT /i/teams/:tid/features/backgroundEffects/(un)?locked`.
+They remain available through v16. The aggregate endpoints
+`GET /feature-configs` and `GET /teams/:tid/features` are unaffected and continue
+to include `backgroundEffects` at all API versions: the aggregate feature list is
+version-agnostic, like other version-gated features such as MLS.
diff --git a/charts/wire-server/templates/galley/configmap.yaml b/charts/wire-server/templates/galley/configmap.yaml
index 90ed86d571f..006b6a83521 100644
--- a/charts/wire-server/templates/galley/configmap.yaml
+++ b/charts/wire-server/templates/galley/configmap.yaml
@@ -256,9 +256,5 @@ data:
         meetings:
           {{- toYaml .settings.featureFlags.meetings | nindent 10 }}
         {{- end }}
-        {{- if .settings.featureFlags.backgroundEffects }}
-        backgroundEffects:
-          {{- toYaml .settings.featureFlags.backgroundEffects | nindent 10 }}
-        {{- end }}
       {{- end }}
   {{- end }}
diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml
index 89e688a2ef0..0e14993cf88 100644
--- a/charts/wire-server/values.yaml
+++ b/charts/wire-server/values.yaml
@@ -341,10 +341,6 @@ galley:
           defaults:
             status: disabled
             lockStatus: locked
-        backgroundEffects:
-          defaults:
-            status: disabled
-            lockStatus: locked
     aws:
       region: "eu-west-1"
     proxy: {}
diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md
index 7ae112d8511..1ee95bcc006 100644
--- a/docs/src/developer/reference/config-options.md
+++ b/docs/src/developer/reference/config-options.md
@@ -300,19 +300,20 @@ The aggregate list endpoints (`GET /feature-configs`,
 `GET /teams/:tid/features`) continue to include `meetingsPremium` at all API
 versions, including v17.
 
-### Background Effects
+### Background Effects (deprecated)
 
-The `backgroundEffects` feature flag controls whether background effects are available in meetings. It is disabled and locked by default. If you want a different configuration, use the following syntax: 
-```yaml
-backgroundEffects:
-  defaults:
-    status: disabled|enabled
-    lockStatus: locked|unlocked
-```
+> **Deprecated (WPB-27912).** The `backgroundEffects` feature flag no longer
+> affects meeting behaviour. The flag, its data type and its public/internal
+> endpoints are retained for backward compatibility and are scheduled for
+> removal in a future release.
 
-The lock status for individual teams can be changed via the internal API (`PUT /i/teams/:tid/features/backgroundEffects/(un)?locked`).
-
-The feature status for individual teams can be changed via the public API (if the feature is unlocked).
+The flag now defaults to **enabled and locked** and the Helm configuration
+override has been removed (operators can no longer change it via Helm). The
+`GET/PUT /teams/:tid/features/backgroundEffects` and internal lock-status
+endpoints return 404 at API version v17; they remain available through v16.
+The aggregate endpoints `GET /feature-configs` and
+`GET /teams/:tid/features` continue to include `backgroundEffects` at all API
+versions, including v17.
 
 ### File Sharing
 
diff --git a/integration/test/Test/FeatureFlags.hs b/integration/test/Test/FeatureFlags.hs
index 7dc7c59f5b6..d93c15df60e 100644
--- a/integration/test/Test/FeatureFlags.hs
+++ b/integration/test/Test/FeatureFlags.hs
@@ -96,7 +96,7 @@ testNonMemberAccess (Feature featureName) = do
   -- authz check (403 no-team-member) is still exercised.
   let getFeature = Public.getTeamFeature nonMember tid featureName
   resp <-
-    if featureName == "meetingsPremium"
+    if featureName `elem` ["meetingsPremium", "backgroundEffects"]
       then withAPIVersion 16 getFeature
       else getFeature
   assertForbidden resp
diff --git a/integration/test/Test/FeatureFlags/BackgroundEffects.hs b/integration/test/Test/FeatureFlags/BackgroundEffects.hs
index c2a568a0b84..5db0a5a6e2c 100644
--- a/integration/test/Test/FeatureFlags/BackgroundEffects.hs
+++ b/integration/test/Test/FeatureFlags/BackgroundEffects.hs
@@ -17,14 +17,49 @@
 
 module Test.FeatureFlags.BackgroundEffects where
 
+import SetupHelpers (createTeam)
 import Test.FeatureFlags.Util
 import Testlib.Prelude
 
 testPatchBackgroundEffects :: (HasCallStack) => App ()
-testPatchBackgroundEffects = checkPatch OwnDomain "backgroundEffects" enabled
+testPatchBackgroundEffects = withAPIVersion 16 $ checkPatch OwnDomain "backgroundEffects" enabled
 
 testBackgroundEffects :: (HasCallStack) => APIAccess -> App ()
 testBackgroundEffects access =
-  mkFeatureTests "backgroundEffects"
+  withAPIVersion 16
+    $ mkFeatureTests "backgroundEffects"
     & addUpdate enabled
     & runFeatureTests OwnDomain access
+
+-- | WPB-27912: the public backgroundEffects endpoints are gated at v17 (404)
+-- while remaining available through v16. Only the v16 GET is asserted here:
+-- v16 PUT success is covered by 'testBackgroundEffects' (whose runFeatureTests
+-- unlocks the feature first), and a public PUT in this test would 409
+-- feature-locked against the default enabled+locked state.
+testBackgroundEffectsRemovedAtV17 :: (HasCallStack) => App ()
+testBackgroundEffectsRemovedAtV17 = do
+  (owner, tid, []) <- createTeam OwnDomain 0
+  let p = joinHttpPath ["teams", tid, "features", "backgroundEffects"]
+      body = object ["status" .= "enabled", "lockStatus" .= "locked"]
+  bindResponse (baseRequest owner Galley (ExplicitVersion 17) p >>= submit "GET") $ assertStatus 404
+  bindResponse (baseRequest owner Galley (ExplicitVersion 17) p <&> addJSON body >>= submit "PUT") $ assertStatus 404
+  bindResponse (baseRequest owner Galley (ExplicitVersion 16) p >>= submit "GET") $ \resp -> do
+    resp.status `shouldMatchInt` 200
+    resp.json %. "status" `shouldMatch` "enabled"
+    resp.json %. "lockStatus" `shouldMatch` "locked"
+
+-- | Test version agnostic feature endpoints for `backgroundEffects`
+--
+-- Across versions, `backgroundEffects` is always set to `enabled` and `locked` to provide backwards compatibility.
+-- From `V17` on, the feature itself  has been removed.
+testBackgroundEffectsListedAtV17 :: (HasCallStack) => App ()
+testBackgroundEffectsListedAtV17 = do
+  (owner, tid, []) <- createTeam OwnDomain 0
+  let assertBackgroundEffects resp = do
+        resp.status `shouldMatchInt` 200
+        be <- resp.json %. "backgroundEffects"
+        be %. "status" `shouldMatch` "enabled"
+        be %. "lockStatus" `shouldMatch` "locked"
+      teamFeatures = joinHttpPath ["teams", tid, "features"]
+  bindResponse (baseRequest owner Galley (ExplicitVersion 17) "/feature-configs" >>= submit "GET") assertBackgroundEffects
+  bindResponse (baseRequest owner Galley (ExplicitVersion 17) teamFeatures >>= submit "GET") assertBackgroundEffects
diff --git a/integration/test/Test/FeatureFlags/Util.hs b/integration/test/Test/FeatureFlags/Util.hs
index da69f27a415..58d7da16cff 100644
--- a/integration/test/Test/FeatureFlags/Util.hs
+++ b/integration/test/Test/FeatureFlags/Util.hs
@@ -250,7 +250,7 @@ defAllFeatures =
           ],
       "meetings" .= enabled,
       "meetingsPremium" .= enabledLocked,
-      "backgroundEffects" .= disabledLocked,
+      "backgroundEffects" .= enabledLocked,
       "preventAdminlessGroups"
         .= object
           [ "lockStatus" .= "unlocked",
diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs
index 5cee430ea52..793e66d23d7 100644
--- a/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs
+++ b/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs
@@ -100,7 +100,7 @@ type IFeatureAPI =
     :<|> IFeatureStatusLockStatusPut StealthUsersConfig
     :<|> IFeatureStatusLockStatusPut MeetingsConfig
     :<|> Until 'V17 ::> IFeatureStatusLockStatusPut MeetingsPremiumConfig
-    :<|> IFeatureStatusLockStatusPut BackgroundEffectsConfig
+    :<|> Until 'V17 ::> IFeatureStatusLockStatusPut BackgroundEffectsConfig
     -- all feature configs
     :<|> Named
            "feature-configs-internal"
diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs
index 30033a50f9b..47d3524bf0c 100644
--- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs
+++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs
@@ -86,7 +86,7 @@ type FeatureAPI =
     :<|> FeatureAPIGetPut MeetingsConfig
     :<|> Deprecated ::> Until 'V17 ::> FeatureAPIGet MeetingsPremiumConfig
     :<|> Deprecated ::> Until 'V17 ::> FeatureAPIPut MeetingsPremiumConfig
-    :<|> FeatureAPIGetPut BackgroundEffectsConfig
+    :<|> Deprecated ::> Until 'V17 ::> FeatureAPIGetPut BackgroundEffectsConfig
 
 type VersionedFeatureAPIPut named reqBodyVersion cfg =
   Named
diff --git a/libs/wire-api/src/Wire/API/Team/Feature.hs b/libs/wire-api/src/Wire/API/Team/Feature.hs
index 741ba022b9e..c9f8eef1b09 100644
--- a/libs/wire-api/src/Wire/API/Team/Feature.hs
+++ b/libs/wire-api/src/Wire/API/Team/Feature.hs
@@ -2428,9 +2428,12 @@ instance ToObjectSchema MeetingsPremiumConfig where
 
 --------------------------------------------------------------------------------
 -- BackgroundEffects Feature
---
--- Controls whether background effects are available in meetings.
 
+{-# DEPRECATED BackgroundEffectsConfig "Deprecated (WPB-27912): no longer affects meeting behaviour; kept for API compatibility." #-}
+
+-- | /Deprecated (WPB-27912)./ This feature flag no longer affects meeting
+-- behaviour and is kept solely for API compatibility. It defaults to
+-- /enabled and locked/. Scheduled for removal in a future release.
 data BackgroundEffectsConfig = BackgroundEffectsConfig
   deriving (Eq, Show, Generic, GSOP.Generic)
   deriving (Arbitrary) via (GenericUniform BackgroundEffectsConfig)
@@ -2441,7 +2444,7 @@ instance ToSchema BackgroundEffectsConfig where
   schema = object objectSchema
 
 instance Default (LockableFeature BackgroundEffectsConfig) where
-  def = defLockedFeature
+  def = defLockedFeature {status = FeatureStatusEnabled}
 
 instance IsFeatureConfig BackgroundEffectsConfig where
   type FeatureSymbol BackgroundEffectsConfig = "backgroundEffects"
diff --git a/services/galley/src/Galley/API/Public/Feature.hs b/services/galley/src/Galley/API/Public/Feature.hs
index 29613cc0267..580e65a48a3 100644
--- a/services/galley/src/Galley/API/Public/Feature.hs
+++ b/services/galley/src/Galley/API/Public/Feature.hs
@@ -86,7 +86,7 @@ featureAPI =
     <@> featureAPIGetPut @MeetingsConfig
     <@> mkNamedAPI @'("get", MeetingsPremiumConfig) getFeature
     <@> mkNamedAPI @'("put", MeetingsPremiumConfig) setFeature
-    <@> featureAPIGetPut @BackgroundEffectsConfig
+    <@> hoistAPI id featureAPIGetPut
 
 deprecatedFeatureConfigAPI :: API DeprecatedFeatureAPI GalleyEffects
 deprecatedFeatureConfigAPI =

From 3f9e234eea1848ff391a5a36fb7ff7b9b08e41a0 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 19 Aug 2026 09:10:05 +0200
Subject: [PATCH 092/113] Update cassandra Docker tag to v4.1.11 (#5449)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
 deploy/dockerephemeral/docker-compose.yaml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/deploy/dockerephemeral/docker-compose.yaml b/deploy/dockerephemeral/docker-compose.yaml
index c4ff725a119..2081edddc21 100644
--- a/deploy/dockerephemeral/docker-compose.yaml
+++ b/deploy/dockerephemeral/docker-compose.yaml
@@ -294,7 +294,7 @@ services:
 
   cassandra:
     container_name: demo_wire_cassandra
-    image: cassandra:4.1.10
+    image: cassandra:4.1.11
     ports:
       - "127.0.0.1:9042:9042"
     ulimits:

From 11347e793a39d02a1355136923d19cf62a55b489 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Wed, 19 Aug 2026 09:37:35 +0200
Subject: [PATCH 093/113] WPB-27553: add tzid to meetings (#5391)

---
 .../wpb-27553-meeting-duration-tzid           |   6 +
 .../wpb-27553-meeting-duration-tzid           |   4 +
 .../5-internal/wpb-27553-meeting-tzid-notnull |   3 +
 .../templates/galley/configmap.yaml           |   1 +
 charts/wire-server/values.yaml                |   1 +
 .../src/developer/reference/config-options.md |   7 +
 hack/helm_vars/wire-server/values.yaml.gotmpl |   1 +
 integration/test/API/Galley.hs                |  10 +
 integration/test/Test/Meetings.hs             |  71 ++++
 libs/wire-api/default.nix                     |   2 +
 libs/wire-api/src/Wire/API/Meeting.hs         | 309 ++++++++++++++----
 .../Wire/API/Routes/Public/Galley/Meetings.hs |  17 +-
 .../golden/Test/Wire/API/Golden/Manual.hs     |   4 +-
 .../Test/Wire/API/Golden/Manual/Meeting.hs    |  32 ++
 .../golden/testObject_Meeting_manual_1.json   |   1 +
 .../golden/testObject_Meeting_manual_2.json   |   1 +
 .../test/unit/Test/Wire/API/Meeting.hs        |  41 +++
 .../unit/Test/Wire/API/Roundtrip/Aeson.hs     |  10 +-
 libs/wire-api/test/unit/Test/Wire/API/Run.hs  |   2 +
 libs/wire-api/wire-api.cabal                  |   2 +
 .../20260729120000-meetings-tzid.sql          |   1 +
 .../20260729120500-meetings-tzid-notnull.sql  |   6 +
 .../wire-subsystems/src/Wire/MeetingsStore.hs |  17 +-
 .../src/Wire/MeetingsStore/Postgres.hs        |  51 +--
 .../src/Wire/MeetingsSubsystem.hs             |  18 +
 .../src/Wire/MeetingsSubsystem/Interpreter.hs |  48 ++-
 .../src/Wire/Options/Galley.hs                |   8 +-
 .../test/unit/Wire/MeetingNotifierSpec.hs     |   2 +
 .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 201 +++++++++++-
 .../Wire/MockInterpreters/MeetingsStore.hs    |   7 +-
 postgres-schema.sql                           |   1 +
 .../src/Wire/MeetingsCleanupWorker.hs         |   3 +-
 services/galley/galley.integration.yaml       |   1 +
 services/galley/src/Galley/API/Meetings.hs    |  54 ++-
 .../galley/src/Galley/API/Public/Meetings.hs  |   8 +-
 services/galley/src/Galley/App.hs             |   5 +-
 36 files changed, 811 insertions(+), 145 deletions(-)
 create mode 100644 changelog.d/0-release-notes/wpb-27553-meeting-duration-tzid
 create mode 100644 changelog.d/1-api-changes/wpb-27553-meeting-duration-tzid
 create mode 100644 changelog.d/5-internal/wpb-27553-meeting-tzid-notnull
 create mode 100644 libs/wire-api/test/unit/Test/Wire/API/Meeting.hs
 create mode 100644 libs/wire-subsystems/postgres-migrations/20260729120000-meetings-tzid.sql
 create mode 100644 libs/wire-subsystems/postgres-migrations/20260729120500-meetings-tzid-notnull.sql

diff --git a/changelog.d/0-release-notes/wpb-27553-meeting-duration-tzid b/changelog.d/0-release-notes/wpb-27553-meeting-duration-tzid
new file mode 100644
index 00000000000..fc465c9c94f
--- /dev/null
+++ b/changelog.d/0-release-notes/wpb-27553-meeting-duration-tzid
@@ -0,0 +1,6 @@
+Starting at API version V17, the `Meeting` type returned and accepted by the
+meetings endpoints carries `tzid` (IANA time zone) and drops the deprecated
+`trial` field; `end_time` is retained on both V17 and V16. The operator config
+`galley.config.settings.meetings.legacyTimeZone` (default `Europe/Berlin`) now
+applies only to meetings created by legacy clients (API < V17); reads no longer
+need it, as `tzid` is persisted `NOT NULL` (backfilled to `Europe/Berlin`).
diff --git a/changelog.d/1-api-changes/wpb-27553-meeting-duration-tzid b/changelog.d/1-api-changes/wpb-27553-meeting-duration-tzid
new file mode 100644
index 00000000000..0163620aa55
--- /dev/null
+++ b/changelog.d/1-api-changes/wpb-27553-meeting-duration-tzid
@@ -0,0 +1,4 @@
+Starting at API version V17, the `Meeting` type carries a `tzid` (IANA time
+zone) and drops the deprecated `trial` field; `end_time` is retained on both V17
+and V16. A V17 update that supplies only `start_time` leaves `end_time`
+unchanged — pass `end_time` to reschedule the end.
diff --git a/changelog.d/5-internal/wpb-27553-meeting-tzid-notnull b/changelog.d/5-internal/wpb-27553-meeting-tzid-notnull
new file mode 100644
index 00000000000..3aff5543e3b
--- /dev/null
+++ b/changelog.d/5-internal/wpb-27553-meeting-tzid-notnull
@@ -0,0 +1,3 @@
+Internal: `meetings.tzid` is now `NOT NULL`, backfilled to `Europe/Berlin`.
+`end_time` is the source of truth (there are no `duration`/`duration_original`
+columns). (WPB-27553)
diff --git a/charts/wire-server/templates/galley/configmap.yaml b/charts/wire-server/templates/galley/configmap.yaml
index 006b6a83521..528fe4cbd72 100644
--- a/charts/wire-server/templates/galley/configmap.yaml
+++ b/charts/wire-server/templates/galley/configmap.yaml
@@ -117,6 +117,7 @@ data:
         {{- if .validityPeriod }}
         validityPeriod: {{ .validityPeriod }}
         {{- end }}
+        legacyTimeZone: {{ .legacyTimeZone }}
         {{- with .email }}
         email:
           from: {{ required "Missing value: galley.config.settings.meetings.email.from" .from | quote }}
diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml
index 0e14993cf88..7d89ce72b91 100644
--- a/charts/wire-server/values.yaml
+++ b/charts/wire-server/values.yaml
@@ -141,6 +141,7 @@ galley:
 
       meetings:
         validityPeriod: "48h"
+        legacyTimeZone: "Europe/Berlin"
         # Optional. When set, meeting invitation emails are sent with this
         # sender over the configured transport (SES xor SMTP). `useSES` selects
         # the transport; `aws` is used when true, `smtp` when false (mirrors
diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md
index 1ee95bcc006..a6ab4af3799 100644
--- a/docs/src/developer/reference/config-options.md
+++ b/docs/src/developer/reference/config-options.md
@@ -282,6 +282,13 @@ points at the path where the SMTP password is read, and
 path into `transport.smtpCredentials.smtpPassword`, the same pattern Brig uses
 for `smtp.passwordFile`.
 
+### Meetings time settings
+
+The `galley.config.settings.meetings.legacyTimeZone` Helm value is an IANA time
+zone id (e.g. `"Europe/Berlin"`, the default) used as the `tzid` for meetings
+created by legacy clients (< V17), which send an `end_time` instead of the V17
+`duration` + `tzid` fields. It has no effect on V17+ clients.
+
 ### Meetings Premium (deprecated)
 
 > **Deprecated (WPB-26771).** The `meetingsPremium` feature flag no longer
diff --git a/hack/helm_vars/wire-server/values.yaml.gotmpl b/hack/helm_vars/wire-server/values.yaml.gotmpl
index bd6cdadf2a8..9478334e701 100644
--- a/hack/helm_vars/wire-server/values.yaml.gotmpl
+++ b/hack/helm_vars/wire-server/values.yaml.gotmpl
@@ -317,6 +317,7 @@ galley:
       disabledAPIVersions: []
       meetings:
         validityPeriod: "5s"
+        legacyTimeZone: "Europe/Berlin"
         email:
           from: meetings@example.com
           replyTo: noreply@example.com
diff --git a/integration/test/API/Galley.hs b/integration/test/API/Galley.hs
index 3d7aeab1754..1f9458c8844 100644
--- a/integration/test/API/Galley.hs
+++ b/integration/test/API/Galley.hs
@@ -1102,3 +1102,13 @@ putMeetingInvitation :: (HasCallStack, MakesValue user) => user -> String -> Str
 putMeetingInvitation user domain meetingId invitation = do
   req <- baseRequest user Galley Versioned (joinHttpPath ["meetings", domain, meetingId, "invitations"])
   submit "PUT" $ req & addJSON invitation
+
+postMeetingsV16 :: (HasCallStack, MakesValue user) => user -> Value -> App Response
+postMeetingsV16 user newMeeting = do
+  req <- baseRequest user Galley (ExplicitVersion 16) "/meetings"
+  submit "POST" $ req & addJSON newMeeting
+
+getMeetingV16 :: (HasCallStack, MakesValue user) => user -> String -> String -> App Response
+getMeetingV16 user domain meetingId = do
+  req <- baseRequest user Galley (ExplicitVersion 16) (joinHttpPath ["meetings", domain, meetingId])
+  submit "GET" req
diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs
index 314a89cf1a2..ae28999b79f 100644
--- a/integration/test/Test/Meetings.hs
+++ b/integration/test/Test/Meetings.hs
@@ -9,6 +9,7 @@ import qualified Data.Text as Text
 import qualified Data.Text.Encoding as Text
 import Data.Time.Clock
 import qualified Data.Time.Format as Time
+import Data.Time.Format.ISO8601 (iso8601ParseM)
 import MLS.Util
 import Notifications (isConvCreateMeetingNotif, isConvDeleteMeetingNotif, isMeetingCreateNotif, isMeetingDeleteNotif, isMeetingMemberAddNotif, isMeetingUpdateNotif, isMemberJoinNotif, isWelcomeNotif)
 import SetupHelpers
@@ -131,6 +132,18 @@ assertMeetingNotif notif qid = do
 -- | Helper to create a default new meeting JSON object
 defaultMeetingJson :: String -> UTCTime -> UTCTime -> [String] -> Value
 defaultMeetingJson title startTime endTime invitedEmails =
+  object
+    [ "title" .= title,
+      "start_time" .= startTime,
+      "end_time" .= endTime,
+      "tzid" .= ("Europe/Berlin" :: String),
+      "invited_emails" .= invitedEmails
+    ]
+
+-- | Legacy (V15/V16) meeting JSON: carries @end_time@ and no @tzid@ (V17 adds
+-- @tzid@). Used to exercise the legacy endpoints in interop tests.
+defaultMeetingJsonLegacy :: String -> UTCTime -> UTCTime -> [String] -> Value
+defaultMeetingJsonLegacy title startTime endTime invitedEmails =
   object
     [ "title" .= title,
       "start_time" .= startTime,
@@ -236,6 +249,7 @@ testMeetingRecurrence = do
           [ "title" .= "Daily Standup with Recurrence",
             "start_time" .= startTime,
             "end_time" .= endTime,
+            "tzid" .= ("Europe/Berlin" :: String),
             "recurrence" .= recurrence,
             "invited_emails" .= ["charlie@example.com"]
           ]
@@ -496,6 +510,7 @@ testMeetingDelete = do
           [ "title" .= "Team Standup",
             "start_time" .= startTime,
             "end_time" .= endTime,
+            "tzid" .= ("Europe/Berlin" :: String),
             "invited_emails" .= ([] :: [String]),
             "recurrence" .= recurrence
           ]
@@ -706,6 +721,7 @@ testMeetingListRecurringNotExpired = do
           [ "title" .= "Recurring Past Meeting",
             "start_time" .= startTime,
             "end_time" .= endTime,
+            "tzid" .= ("Europe/Berlin" :: String),
             "recurrence" .= recurrence,
             "invited_emails" .= ([] :: [String])
           ]
@@ -724,3 +740,58 @@ testMeetingListRecurringNotExpired = do
   assertSuccess resp
   meetings <- resp.json & asList
   length meetings `shouldMatchInt` 1
+
+-- | A meeting created via the V17 shape (@end_time + tzid@) is visible to legacy
+-- clients (< V17) with an @end_time@; V17 reads carry @end_time@ too.
+testMeetingInteropNewToLegacy :: (HasCallStack) => App ()
+testMeetingInteropNewToLegacy = do
+  (owner, _tid, _members) <- createTeam OwnDomain 1
+  now <- liftIO getCurrentTime
+  let startTime = addUTCTime 3600 now
+      endTime = addUTCTime 3600 startTime
+      newMeeting = defaultMeetingJson "Interop New" startTime endTime []
+  meeting <- postMeetings owner newMeeting >>= getJSON 201
+  -- V17 read shape: carries end_time + tzid.
+  startV17 <- meeting %. "start_time" >>= asString
+  endV17 <- meeting %. "end_time" >>= asString
+  startT <- assertJust ("could not parse start_time: " <> startV17) $ iso8601ParseM @Maybe @UTCTime startV17
+  endT <- assertJust ("could not parse end_time: " <> endV17) $ iso8601ParseM @Maybe @UTCTime endV17
+  -- end_time - start_time == 3600s regardless of client/server clock skew; both
+  -- come back from Postgres at microsecond precision.
+  endT `shouldMatch` addUTCTime 3600 startT
+  tzid <- meeting %. "tzid" >>= asString
+  tzid `shouldMatch` ("Europe/Berlin" :: String)
+  -- A meeting whose end is 1h after start (formerly sent as "1h") still reads
+  -- back with end_time = start_time + 3600s on the legacy path.
+  let startTime2 = addUTCTime 7200 now
+      endTime2 = addUTCTime 3600 startTime2
+      newMeeting2 = defaultMeetingJson "Interop New (1h)" startTime2 endTime2 []
+  meeting2 <- postMeetings owner newMeeting2 >>= getJSON 201
+  (meetingId2, domain2) <- getMeetingIdAndDomain meeting2
+  legacy2 <- getMeetingV16 owner domain2 meetingId2 >>= getJSON 200
+  start2Str <- legacy2 %. "start_time" >>= asString
+  end2Str <- legacy2 %. "end_time" >>= asString
+  start2T <- assertJust ("could not parse start_time: " <> start2Str) $ iso8601ParseM @Maybe @UTCTime start2Str
+  end2T <- assertJust ("could not parse end_time: " <> end2Str) $ iso8601ParseM @Maybe @UTCTime end2Str
+  end2T `shouldMatch` addUTCTime 3600 start2T
+
+-- | A meeting created via the legacy shape (@end_time@) is visible to V17 clients
+-- with @end_time@ and the injected default @tzid@ (Europe/Berlin).
+testMeetingInteropLegacyToNew :: (HasCallStack) => App ()
+testMeetingInteropLegacyToNew = do
+  (owner, _tid, _members) <- createTeam OwnDomain 1
+  now <- liftIO getCurrentTime
+  let startTime = addUTCTime 3600 now
+      endTime = addUTCTime 7200 now
+      newMeeting = defaultMeetingJsonLegacy "Interop Legacy" startTime endTime []
+  meeting <- postMeetingsV16 owner newMeeting >>= getJSON 201
+  (meetingId, domain) <- getMeetingIdAndDomain meeting
+  -- V17 read shape: end_time is present, tzid is the injected default.
+  modern <- getMeeting owner domain meetingId >>= getJSON 200
+  startStr <- modern %. "start_time" >>= asString
+  endStr <- modern %. "end_time" >>= asString
+  startT <- assertJust ("could not parse start_time: " <> startStr) $ iso8601ParseM @Maybe @UTCTime startStr
+  endT <- assertJust ("could not parse end_time: " <> endStr) $ iso8601ParseM @Maybe @UTCTime endStr
+  endT `shouldMatch` addUTCTime 3600 startT
+  tzid <- modern %. "tzid" >>= asString
+  tzid `shouldMatch` ("Europe/Berlin" :: String)
diff --git a/libs/wire-api/default.nix b/libs/wire-api/default.nix
index 1c9eaf75c20..ad6aae69343 100644
--- a/libs/wire-api/default.nix
+++ b/libs/wire-api/default.nix
@@ -112,6 +112,7 @@
 , tinylog
 , transformers
 , types-common
+, tz
 , unliftio
 , unordered-containers
 , uri-bytestring
@@ -225,6 +226,7 @@ mkDerivation {
     tinylog
     transformers
     types-common
+    tz
     unordered-containers
     uri-bytestring
     utf8-string
diff --git a/libs/wire-api/src/Wire/API/Meeting.hs b/libs/wire-api/src/Wire/API/Meeting.hs
index a894c2cfa27..20eaaaa3808 100644
--- a/libs/wire-api/src/Wire/API/Meeting.hs
+++ b/libs/wire-api/src/Wire/API/Meeting.hs
@@ -15,10 +15,42 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Wire.API.Meeting where
+module Wire.API.Meeting
+  ( -- * Time zone
+    TimeZone (..),
+    timeZoneTZ,
+    parseTimeZone,
+    renderTimeZone,
+    defaultLegacyTimeZone,
+
+    -- * Meetings (V17 and later)
+    Meeting (..),
+    MeetingWithConversation (..),
+    NewMeeting (..),
+    UpdateMeeting (..),
+
+    -- * Legacy meetings (V15/V16)
+    MeetingV16 (..),
+    MeetingWithConversationV16 (..),
+    NewMeetingV16 (..),
+    UpdateMeetingV16,
+
+    -- * Conversions
+    toLegacy,
+    fromLegacy,
+    toLegacyWithConv,
+    fromLegacyNewMeeting,
+
+    -- * Misc
+    Recurrence (..),
+    Frequency (..),
+    MeetingEmailsInvitation (..),
+  )
+where
 
 import Control.Lens ((?~))
-import Data.Aeson (toJSON)
+import Data.Aeson (FromJSON, ToJSON, toJSON)
+import Data.ByteString.Char8 qualified as BS
 import Data.Id (ConvId, MeetingId, UserId)
 import Data.Int qualified as DI
 import Data.Json.Util (utcTimeSchema)
@@ -26,23 +58,55 @@ import Data.OpenApi qualified as S
 import Data.Qualified (Qualified)
 import Data.Range (Range)
 import Data.Schema
+import Data.Text qualified as Text
 import Data.Time.Clock
-import Deriving.Aeson
+import Data.Time.Zones.All (TZLabel (..), fromTZName, toTZName, tzByLabel)
+import Data.Time.Zones.Types (TZ)
 import Imports
+import Test.QuickCheck (elements)
 import Wire.API.Conversation (Conversation, GroupConvType)
 import Wire.API.PostgresMarshall (PostgresMarshall (..), PostgresUnmarshall (..))
-import Wire.API.Routes.Version
-import Wire.API.Routes.Versioned (Versioned (..))
 import Wire.API.User.Identity (EmailAddress)
-import Wire.Arbitrary (Arbitrary, GenericUniform (..))
+import Wire.Arbitrary (Arbitrary (..), GenericUniform (..))
+
+-- | An IANA time zone identifier (e.g. @"Europe/Paris"@), backed by the @tz@
+-- package's 'TZLabel'. The loaded 'TZ' is recovered purely via 'timeZoneTZ';
+-- 'TZLabel' itself is what is serialized to JSON and Postgres.
+newtype TimeZone = TimeZone {timeZoneLabel :: TZLabel}
+  deriving stock (Eq, Ord, Show, Generic)
+  deriving newtype (Bounded, Enum)
+  deriving (ToJSON, FromJSON, S.ToSchema) via (Schema TimeZone)
+
+timeZoneTZ :: TimeZone -> TZ
+timeZoneTZ = tzByLabel . timeZoneLabel
+
+parseTimeZone :: Text -> Maybe TimeZone
+parseTimeZone = fmap TimeZone . fromTZName . BS.pack . Text.unpack
+
+renderTimeZone :: TimeZone -> Text
+renderTimeZone = Text.pack . BS.unpack . toTZName . timeZoneLabel
+
+-- | Default for legacy operations (helm @meetings.legacyTimeZone@).
+defaultLegacyTimeZone :: TimeZone
+defaultLegacyTimeZone = TimeZone Europe__Berlin
+
+instance ToSchema TimeZone where
+  schema =
+    renderTimeZone
+      .= parsedText "TimeZone" (maybe (Left "invalid IANA tzid") Right . parseTimeZone)
 
--- | Core Meeting type
+instance Arbitrary TimeZone where
+  arbitrary = TimeZone <$> elements [minBound .. maxBound]
+
+-- | A scheduled meeting (V17 and later). @end_time@ is the source of truth
+-- (there is no @duration@ field); the @tzid@ field carries the IANA time zone.
 data Meeting = Meeting
   { id :: Qualified MeetingId,
     title :: Range 1 256 Text,
     creator :: Qualified UserId,
     startTime :: UTCTime,
     endTime :: UTCTime,
+    tzid :: TimeZone,
     recurrence :: Maybe Recurrence,
     conversationId :: Qualified ConvId,
     invitedEmails :: [EmailAddress],
@@ -53,6 +117,27 @@ data Meeting = Meeting
   deriving (ToJSON, FromJSON, S.ToSchema) via (Schema Meeting)
   deriving (Arbitrary) via (GenericUniform Meeting)
 
+-- | A legacy meeting (V15/V16). Carries @end_time@ (the source of truth) but
+-- has no @tzid@ field; the deprecated @trial@ field is injected (always
+-- @false@) in the 'ToSchema' instance and is never stored.
+data MeetingV16 = MeetingV16
+  { id :: Qualified MeetingId,
+    title :: Range 1 256 Text,
+    creator :: Qualified UserId,
+    startTime :: UTCTime,
+    endTime :: UTCTime,
+    recurrence :: Maybe Recurrence,
+    conversationId :: Qualified ConvId,
+    invitedEmails :: [EmailAddress],
+    createdAt :: UTCTime,
+    updatedAt :: UTCTime
+  }
+  deriving stock (Eq, Show, Generic)
+  deriving (ToJSON, FromJSON, S.ToSchema) via (Schema MeetingV16)
+  deriving (Arbitrary) via (GenericUniform MeetingV16)
+
+-- | V17+ object schema. Carries @end_time@ directly (the source of truth) and
+-- the @tzid@ field.
 meetingObject :: ObjectSchema SwaggerDoc Meeting
 meetingObject =
   Meeting
@@ -61,48 +146,49 @@ meetingObject =
     <*> (.creator) .= field "qualified_creator" schema
     <*> (.startTime) .= field "start_time" utcTimeSchema
     <*> (.endTime) .= field "end_time" utcTimeSchema
+    <*> (.tzid) .= field "tzid" schema
     <*> (.recurrence) .= maybe_ (optField "recurrence" schema)
     <*> (.conversationId) .= field "qualified_conversation" schema
     <*> (.invitedEmails) .= field "invited_emails" (array schema)
     <*> (.createdAt) .= field "created_at" utcTimeSchema
     <*> (.updatedAt) .= field "updated_at" utcTimeSchema
 
--- | 'meetingObject' for a given API version. Legacy versions (< V17) additionally
--- render the deprecated @trial@ field (always 'False'); V17 and later omit it.
-meetingObjectVersioned :: Maybe Version -> ObjectSchema SwaggerDoc Meeting
-meetingObjectVersioned v
-  | maybe False (< V17) v =
-      meetingObject
-        <* ( const ()
-               .= fieldWithDocModifier
-                 "trial"
-                 (description ?~ "Deprecated. Always false; team meetings are never trial.")
-                 (c (False :: Bool))
-           )
-  | otherwise = meetingObject
+instance ToSchema Meeting where
+  schema = objectWithDocModifier (description ?~ "A scheduled meeting") meetingObject
+
+-- | V16 (V15/V16) object schema. Keeps @end_time@ and appends the always-false
+-- @trial@ field (never stored).
+meetingV16Object :: ObjectSchema SwaggerDoc MeetingV16
+meetingV16Object =
+  MeetingV16
+    <$> (.id) .= field "qualified_id" schema
+    <*> (.title) .= field "title" schema
+    <*> (.creator) .= field "qualified_creator" schema
+    <*> (.startTime) .= field "start_time" utcTimeSchema
+    <*> (.endTime) .= field "end_time" utcTimeSchema
+    <*> (.recurrence) .= maybe_ (optField "recurrence" schema)
+    <*> (.conversationId) .= field "qualified_conversation" schema
+    <*> (.invitedEmails) .= field "invited_emails" (array schema)
+    <*> (.createdAt) .= field "created_at" utcTimeSchema
+    <*> (.updatedAt) .= field "updated_at" utcTimeSchema
+    <* ( const ()
+           .= fieldWithDocModifier
+             "trial"
+             (description ?~ "Deprecated. Always false; team meetings are never trial.")
+             (c (False :: Bool))
+       )
   where
     -- Constant schema that always encodes @val@ and decodes to @()@, cf. the
     -- @managed@ field of 'Wire.API.Conversation.ConvTeamInfo'.
     c :: (ToJSON a) => a -> ValueSchema SwaggerDoc ()
     c val = mkSchema mempty (const (pure ())) (const (pure (toJSON val)))
 
--- | Swagger-named ('ValueSchema') form of 'meetingObjectVersioned', used by the
--- plain 'ToSchema' instance and the versioned 'Versioned' instances.
-meetingSchema :: Maybe Version -> ValueSchema NamedSwaggerDoc Meeting
-meetingSchema v =
-  versionedObjectWithDocModifier v (description ?~ "A scheduled meeting") (meetingObjectVersioned v)
-
-instance ToSchema Meeting where
-  schema = meetingSchema Nothing
-
-instance ToSchema (Versioned 'V15 Meeting) where
-  schema = Versioned <$> unVersioned .= meetingSchema (Just V15)
+instance ToSchema MeetingV16 where
+  schema = objectWithDocModifier (description ?~ "A scheduled meeting") meetingV16Object
 
 -- | A 'Meeting' extended with the full 'Conversation' associated with it, as
--- returned when creating or updating a meeting. The underlying 'Meeting' is
--- reused (no field duplication) and flattened into the JSON object in the
--- 'ToSchema' instance, so that the legacy @qualified_conversation@ field and
--- the full @conversation@ are returned alongside the meeting fields.
+-- returned when creating or updating a meeting. The underlying meeting fields
+-- are flattened into the JSON object (emitted alongside @conversation@).
 data MeetingWithConversation = MeetingWithConversation
   { meeting :: Meeting,
     conversation :: Conversation GroupConvType
@@ -111,37 +197,46 @@ data MeetingWithConversation = MeetingWithConversation
   deriving (ToJSON, FromJSON, S.ToSchema) via (Schema MeetingWithConversation)
   deriving (Arbitrary) via (GenericUniform MeetingWithConversation)
 
-meetingWithConversationObject :: Maybe Version -> ObjectSchema SwaggerDoc MeetingWithConversation
-meetingWithConversationObject v =
+data MeetingWithConversationV16 = MeetingWithConversationV16
+  { meeting :: MeetingV16,
+    conversation :: Conversation GroupConvType
+  }
+  deriving stock (Eq, Show, Generic)
+  deriving (ToJSON, FromJSON, S.ToSchema) via (Schema MeetingWithConversationV16)
+  deriving (Arbitrary) via (GenericUniform MeetingWithConversationV16)
+
+-- | The V17+ meeting object is flattened into the parent object (its fields
+-- are emitted alongside @conversation@ rather than nested).
+meetingWithConversationObject :: ObjectSchema SwaggerDoc MeetingWithConversation
+meetingWithConversationObject =
   MeetingWithConversation
-    <$> (.meeting) .= meetingObjectVersioned v
+    <$> (.meeting) .= meetingObject
     <*> (.conversation) .= field "conversation" schema
 
-meetingWithConversationSchema :: Maybe Version -> ValueSchema NamedSwaggerDoc MeetingWithConversation
-meetingWithConversationSchema v =
-  versionedObjectWithDocModifier
-    v
-    (description ?~ "A scheduled meeting with its associated conversation")
-    (meetingWithConversationObject v)
-
 instance ToSchema MeetingWithConversation where
-  schema = meetingWithConversationSchema Nothing
+  schema =
+    objectWithDocModifier
+      (description ?~ "A scheduled meeting with its associated conversation")
+      meetingWithConversationObject
 
-instance ToSchema (Versioned 'V15 MeetingWithConversation) where
-  schema = Versioned <$> unVersioned .= meetingWithConversationSchema (Just V15)
+meetingWithConversationV16Object :: ObjectSchema SwaggerDoc MeetingWithConversationV16
+meetingWithConversationV16Object =
+  MeetingWithConversationV16
+    <$> (.meeting) .= meetingV16Object
+    <*> (.conversation) .= field "conversation" schema
 
--- | Legacy 'Meeting' list (V16) still renders the deprecated @trial@ field
--- (always 'False') for backwards compatibility.
-instance {-# OVERLAPPING #-} ToSchema (Versioned 'V16 [Meeting]) where
+instance ToSchema MeetingWithConversationV16 where
   schema =
-    Versioned
-      <$> unVersioned
-        .= named "MeetingListV16" (array (meetingSchema (Just V16)))
+    objectWithDocModifier
+      (description ?~ "A scheduled meeting with its associated conversation")
+      meetingWithConversationV16Object
 
--- | Request to create a new meeting
+-- | Request to create a new meeting (V17 and later). Carries @end_time@ (the
+-- source of truth) and the @tzid@ field.
 data NewMeeting = NewMeeting
   { startTime :: UTCTime,
     endTime :: UTCTime,
+    tzid :: TimeZone,
     recurrence :: Maybe Recurrence,
     title :: Range 1 256 Text,
     invitedEmails :: [EmailAddress]
@@ -150,6 +245,39 @@ data NewMeeting = NewMeeting
   deriving (ToJSON, FromJSON, S.ToSchema) via (Schema NewMeeting)
   deriving (Arbitrary) via (GenericUniform NewMeeting)
 
+-- | Request to create a new meeting (V15/V16). Carries @end_time@ but no @tzid@.
+data NewMeetingV16 = NewMeetingV16
+  { startTime :: UTCTime,
+    endTime :: UTCTime,
+    recurrence :: Maybe Recurrence,
+    title :: Range 1 256 Text,
+    invitedEmails :: [EmailAddress]
+  }
+  deriving stock (Eq, Show, Generic)
+  deriving (ToJSON, FromJSON, S.ToSchema) via (Schema NewMeetingV16)
+  deriving (Arbitrary) via (GenericUniform NewMeetingV16)
+
+instance ToSchema NewMeeting where
+  schema =
+    objectWithDocModifier (description ?~ "Request to create a new meeting") $
+      NewMeeting
+        <$> (.startTime) .= field "start_time" utcTimeSchema
+        <*> (.endTime) .= field "end_time" utcTimeSchema
+        <*> (.tzid) .= field "tzid" schema
+        <*> (.recurrence) .= maybe_ (optField "recurrence" schema)
+        <*> (.title) .= field "title" schema
+        <*> (.invitedEmails) .= (fromMaybe [] <$> optField "invited_emails" (array schema))
+
+instance ToSchema NewMeetingV16 where
+  schema =
+    objectWithDocModifier (description ?~ "Request to create a new meeting (V16)") $
+      NewMeetingV16
+        <$> (.startTime) .= field "start_time" utcTimeSchema
+        <*> (.endTime) .= field "end_time" utcTimeSchema
+        <*> (.recurrence) .= maybe_ (optField "recurrence" schema)
+        <*> (.title) .= field "title" schema
+        <*> (.invitedEmails) .= (fromMaybe [] <$> optField "invited_emails" (array schema))
+
 data Recurrence = Recurrence
   { -- | The interval between occurrences, e.g., every 2 weeks for Weekly frequency with interval=2
     freq :: Frequency,
@@ -175,17 +303,9 @@ instance ToSchema Frequency where
           element "yearly" Yearly
         ]
 
-instance ToSchema NewMeeting where
-  schema =
-    objectWithDocModifier (description ?~ "Request to create a new meeting") $
-      NewMeeting
-        <$> (.startTime) .= field "start_time" utcTimeSchema
-        <*> (.endTime) .= field "end_time" utcTimeSchema
-        <*> (.recurrence) .= maybe_ (optField "recurrence" schema)
-        <*> (.title) .= field "title" schema
-        <*> (.invitedEmails) .= (fromMaybe [] <$> optField "invited_emails" (array schema))
-
--- | Request to update an existing meeting
+-- | Request to update an existing meeting. Updates carry no @tzid@ (it is
+-- immutable after creation); @end_time@ is optional on both eras, so a single
+-- type serves V17 ('UpdateMeeting') and V16 ('UpdateMeetingV16').
 data UpdateMeeting = UpdateMeeting
   { startTime :: Maybe UTCTime,
     endTime :: Maybe UTCTime,
@@ -197,6 +317,8 @@ data UpdateMeeting = UpdateMeeting
   deriving (ToJSON, FromJSON, S.ToSchema) via (Schema UpdateMeeting)
   deriving (Arbitrary) via (GenericUniform UpdateMeeting)
 
+type UpdateMeetingV16 = UpdateMeeting
+
 instance ToSchema UpdateMeeting where
   schema =
     objectWithDocModifier (description ?~ "Request to update a meeting") $
@@ -214,6 +336,61 @@ instance ToSchema Recurrence where
         <*> (.interval) .= (fromMaybe 1 <$> optField "interval" schema)
         <*> (.until) .= maybe_ (optField "until" utcTimeSchema)
 
+-- | Convert a V17 'Meeting' to the legacy 'MeetingV16' shape. Fields are
+-- copied verbatim; @tzid@ is dropped (@end_time@ is preserved, so no duration
+-- needs to be recomputed).
+toLegacy :: Meeting -> MeetingV16
+toLegacy m =
+  MeetingV16
+    { id = m.id,
+      title = m.title,
+      creator = m.creator,
+      startTime = m.startTime,
+      endTime = m.endTime,
+      recurrence = m.recurrence,
+      conversationId = m.conversationId,
+      invitedEmails = m.invitedEmails,
+      createdAt = m.createdAt,
+      updatedAt = m.updatedAt
+    }
+
+-- | Convert a legacy 'MeetingV16' to the V17 'Meeting' shape, injecting the
+-- given 'TimeZone' as @tzid@. All other fields (including @end_time@) are
+-- preserved.
+fromLegacy :: TimeZone -> MeetingV16 -> Meeting
+fromLegacy tz m =
+  Meeting
+    { id = m.id,
+      title = m.title,
+      creator = m.creator,
+      startTime = m.startTime,
+      endTime = m.endTime,
+      tzid = tz,
+      recurrence = m.recurrence,
+      conversationId = m.conversationId,
+      invitedEmails = m.invitedEmails,
+      createdAt = m.createdAt,
+      updatedAt = m.updatedAt
+    }
+
+-- | 'toLegacy' lifted over 'MeetingWithConversation'.
+toLegacyWithConv :: MeetingWithConversation -> MeetingWithConversationV16
+toLegacyWithConv mwc =
+  MeetingWithConversationV16 {meeting = toLegacy mwc.meeting, conversation = mwc.conversation}
+
+-- | Convert a V16 'NewMeetingV16' to the V17 'NewMeeting', injecting the given
+-- 'TimeZone' as @tzid@. @end_time@ is preserved (it is the source of truth).
+fromLegacyNewMeeting :: TimeZone -> NewMeetingV16 -> NewMeeting
+fromLegacyNewMeeting tz nm =
+  NewMeeting
+    { startTime = nm.startTime,
+      endTime = nm.endTime,
+      tzid = tz,
+      recurrence = nm.recurrence,
+      title = nm.title,
+      invitedEmails = nm.invitedEmails
+    }
+
 -- | Request to add/remove invited email
 newtype MeetingEmailsInvitation = MeetingEmailsInvitation
   { emails :: [EmailAddress]
diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs
index f692e2ab525..4fb6b186742 100644
--- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs
+++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs
@@ -27,7 +27,6 @@ import Wire.API.Routes.MultiVerb
 import Wire.API.Routes.Named
 import Wire.API.Routes.Public
 import Wire.API.Routes.Version
-import Wire.API.Routes.Versioned
 
 type MeetingsAPI =
   Named
@@ -38,14 +37,14 @@ type MeetingsAPI =
         :> ZLocalUser
         :> ZConn
         :> "meetings"
-        :> ReqBody '[JSON] NewMeeting
+        :> ReqBody '[JSON] NewMeetingV16
         :> CanThrow 'InvalidOperation
         :> CanThrow UnreachableBackends
         :> MultiVerb
              'POST
              '[JSON]
-             '[VersionedRespond 'V15 201 "Meeting created" MeetingWithConversation]
-             MeetingWithConversation
+             '[Respond 201 "Meeting created" MeetingWithConversationV16]
+             MeetingWithConversationV16
     )
     :<|> Named
            "create-meeting"
@@ -76,12 +75,12 @@ type MeetingsAPI =
                :> CanThrow 'MeetingNotFound
                :> CanThrow 'AccessDenied
                :> CanThrow 'InvalidOperation
-               :> ReqBody '[JSON] UpdateMeeting
+               :> ReqBody '[JSON] UpdateMeetingV16
                :> MultiVerb
                     'PUT
                     '[JSON]
-                    '[VersionedRespond 'V15 200 "Meeting updated" MeetingWithConversation]
-                    MeetingWithConversation
+                    '[Respond 200 "Meeting updated" MeetingWithConversationV16]
+                    MeetingWithConversationV16
            )
     :<|> Named
            "update-meeting"
@@ -132,7 +131,7 @@ type MeetingsAPI =
                :> MultiVerb1
                     'GET
                     '[JSON]
-                    (VersionedRespond 'V15 200 "A single meeting by ID" Meeting)
+                    (Respond 200 "A single meeting by ID" MeetingV16)
            )
     :<|> Named
            "get-meeting"
@@ -156,7 +155,7 @@ type MeetingsAPI =
                :> MultiVerb1
                     'GET
                     '[JSON]
-                    (VersionedRespond 'V16 200 "List of meetings for the authenticated user" [Meeting])
+                    (Respond 200 "List of meetings for the authenticated user" [MeetingV16])
            )
     :<|> Named
            "list-meetings"
diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs
index 1b5ff536427..37d8a25650c 100644
--- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs
+++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs
@@ -173,8 +173,8 @@ tests =
           ],
       testGroup "Meeting V15" $
         testObjects
-          [ (Versioned @'V15 testObject_Meeting_manual_1, "testObject_Meeting_v15_manual_1.json"),
-            (Versioned @'V15 testObject_Meeting_manual_2, "testObject_Meeting_v15_manual_2.json")
+          [ (testObject_MeetingV16_manual_1, "testObject_Meeting_v15_manual_1.json"),
+            (testObject_MeetingV16_manual_2, "testObject_Meeting_v15_manual_2.json")
           ],
       testGroup "Meeting" $
         testObjects
diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/Meeting.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/Meeting.hs
index 1b08a627668..ee6c10a5c4e 100644
--- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/Meeting.hs
+++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/Meeting.hs
@@ -35,6 +35,7 @@ testObject_Meeting_manual_1 =
       creator = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000002-0000-0000-0000-000000000002")), qDomain = Domain {_domainText = "example.com"}},
       startTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0},
       endTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 3600},
+      tzid = defaultLegacyTimeZone,
       recurrence = Nothing,
       conversationId = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000003-0000-0000-0000-000000000003")), qDomain = Domain {_domainText = "example.com"}},
       invitedEmails = [unsafeEmailAddress "someone" "example.com"],
@@ -45,6 +46,37 @@ testObject_Meeting_manual_1 =
 testObject_Meeting_manual_2 :: Meeting
 testObject_Meeting_manual_2 =
   Meeting
+    { id = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000004-0000-0000-0000-000000000004")), qDomain = Domain {_domainText = "example.com"}},
+      title = unsafeRange "Sprint Planning",
+      creator = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000005-0000-0000-0000-000000000005")), qDomain = Domain {_domainText = "example.com"}},
+      startTime = UTCTime {utctDay = ModifiedJulianDay 58120, utctDayTime = 0},
+      endTime = UTCTime {utctDay = ModifiedJulianDay 58120, utctDayTime = 5400},
+      tzid = defaultLegacyTimeZone,
+      recurrence = Just (Recurrence {freq = Weekly, interval = 1, until = Nothing}),
+      conversationId = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000006-0000-0000-0000-000000000006")), qDomain = Domain {_domainText = "example.com"}},
+      invitedEmails = [],
+      createdAt = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0},
+      updatedAt = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}
+    }
+
+testObject_MeetingV16_manual_1 :: MeetingV16
+testObject_MeetingV16_manual_1 =
+  MeetingV16
+    { id = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001")), qDomain = Domain {_domainText = "example.com"}},
+      title = unsafeRange "Weekly Sync",
+      creator = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000002-0000-0000-0000-000000000002")), qDomain = Domain {_domainText = "example.com"}},
+      startTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0},
+      endTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 3600},
+      recurrence = Nothing,
+      conversationId = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000003-0000-0000-0000-000000000003")), qDomain = Domain {_domainText = "example.com"}},
+      invitedEmails = [unsafeEmailAddress "someone" "example.com"],
+      createdAt = UTCTime {utctDay = ModifiedJulianDay 58118, utctDayTime = 0},
+      updatedAt = UTCTime {utctDay = ModifiedJulianDay 58118, utctDayTime = 0}
+    }
+
+testObject_MeetingV16_manual_2 :: MeetingV16
+testObject_MeetingV16_manual_2 =
+  MeetingV16
     { id = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000004-0000-0000-0000-000000000004")), qDomain = Domain {_domainText = "example.com"}},
       title = unsafeRange "Sprint Planning",
       creator = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000005-0000-0000-0000-000000000005")), qDomain = Domain {_domainText = "example.com"}},
diff --git a/libs/wire-api/test/golden/testObject_Meeting_manual_1.json b/libs/wire-api/test/golden/testObject_Meeting_manual_1.json
index 454e0339d71..4e64a10d6f3 100644
--- a/libs/wire-api/test/golden/testObject_Meeting_manual_1.json
+++ b/libs/wire-api/test/golden/testObject_Meeting_manual_1.json
@@ -18,5 +18,6 @@
     },
     "start_time": "2018-01-01T00:00:00Z",
     "title": "Weekly Sync",
+    "tzid": "Europe/Berlin",
     "updated_at": "2017-12-31T00:00:00Z"
 }
diff --git a/libs/wire-api/test/golden/testObject_Meeting_manual_2.json b/libs/wire-api/test/golden/testObject_Meeting_manual_2.json
index 916c1fdfbc0..347bc45f850 100644
--- a/libs/wire-api/test/golden/testObject_Meeting_manual_2.json
+++ b/libs/wire-api/test/golden/testObject_Meeting_manual_2.json
@@ -20,5 +20,6 @@
     },
     "start_time": "2018-01-02T00:00:00Z",
     "title": "Sprint Planning",
+    "tzid": "Europe/Berlin",
     "updated_at": "2018-01-01T00:00:00Z"
 }
diff --git a/libs/wire-api/test/unit/Test/Wire/API/Meeting.hs b/libs/wire-api/test/unit/Test/Wire/API/Meeting.hs
new file mode 100644
index 00000000000..a6cfc35f9d5
--- /dev/null
+++ b/libs/wire-api/test/unit/Test/Wire/API/Meeting.hs
@@ -0,0 +1,41 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+{-# LANGUAGE OverloadedRecordDot #-}
+
+module Test.Wire.API.Meeting where
+
+import Test.Tasty
+import Test.Tasty.QuickCheck (Property, testProperty, (===))
+import Wire.API.Meeting
+
+tests :: TestTree
+tests =
+  testGroup
+    "Meeting"
+    [ testProperty "toLegacy . fromLegacy === id (V16)" toLegacyFromLegacy,
+      testProperty "fromLegacy . toLegacy === id (V17)" fromLegacyToLegacy
+    ]
+
+-- | V16->V17->V16 round-trips: @end_time@ (the source of truth) is preserved
+-- verbatim, so the legacy shape is recovered exactly.
+toLegacyFromLegacy :: TimeZone -> MeetingV16 -> Property
+toLegacyFromLegacy tz lm = toLegacy (fromLegacy tz lm) === lm
+
+-- | V17->V16->V17 round-trips when the injected @tzid@ matches the original;
+-- @end_time@ is preserved, so all non-tzid fields are recovered exactly.
+fromLegacyToLegacy :: Meeting -> Property
+fromLegacyToLegacy m = fromLegacy m.tzid (toLegacy m) === m
diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs
index c1b8f909df7..14f6f83c610 100644
--- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs
+++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs
@@ -61,9 +61,7 @@ import Wire.API.Push.Token qualified as Push.Token
 import Wire.API.Routes.FederationDomainConfig qualified as FederationDomainConfig
 import Wire.API.Routes.Internal.Brig.EJPD qualified as EJPD
 import Wire.API.Routes.Internal.Galley.TeamsIntra qualified as TeamsIntra
-import Wire.API.Routes.Version (Version (V15, V16))
 import Wire.API.Routes.Version qualified as Routes.Version
-import Wire.API.Routes.Versioned (Versioned (..))
 import Wire.API.SystemSettings qualified as SystemSettings
 import Wire.API.Team qualified as Team
 import Wire.API.Team.Conversation qualified as Team.Conversation
@@ -399,8 +397,8 @@ tests =
       testRoundTrip @Team.LegalHold.Internal.LegalHoldClientRequest,
       meetingTrialVersioningTests,
       testRoundTripWithSwagger @Meeting.Meeting,
-      testRoundTripWithSwagger @(Versioned 'V15 Meeting.Meeting),
-      testRoundTripWithSwagger @(Versioned 'V16 [Meeting.Meeting]),
+      testRoundTripWithSwagger @Meeting.MeetingV16,
+      testRoundTripWithSwagger @[Meeting.MeetingV16],
       testFeatureFlagsCanonicalJsonRoundtrip
     ]
 
@@ -458,8 +456,8 @@ meetingTrialVersioningTests =
   T.testGroup
     "Meeting trial field versioning"
     [ testProperty "legacy (V15) response renders trial=false" $
-        \(m :: Meeting.Meeting) ->
-          trialField (toJSON (Versioned @'V15 m)) === Just False,
+        \(m :: Meeting.MeetingV16) ->
+          trialField (toJSON m) === Just False,
       testProperty "current (V17) response omits trial" $
         \(m :: Meeting.Meeting) ->
           trialField (toJSON m) === Nothing
diff --git a/libs/wire-api/test/unit/Test/Wire/API/Run.hs b/libs/wire-api/test/unit/Test/Wire/API/Run.hs
index cf0f89456c3..6a909534def 100644
--- a/libs/wire-api/test/unit/Test/Wire/API/Run.hs
+++ b/libs/wire-api/test/unit/Test/Wire/API/Run.hs
@@ -24,6 +24,7 @@ import Test.Wire.API.Call.Config qualified as Call.Config
 import Test.Wire.API.Conversation qualified as Conversation
 import Test.Wire.API.MLS qualified as MLS
 import Test.Wire.API.MLS.Group qualified as Group
+import Test.Wire.API.Meeting qualified as Meeting
 import Test.Wire.API.OAuth qualified as OAuth
 import Test.Wire.API.RawJson qualified as RawJson
 import Test.Wire.API.Roundtrip.Aeson qualified as Roundtrip.Aeson
@@ -63,6 +64,7 @@ main =
         Roundtrip.CSV.tests,
         Routes.tests,
         Conversation.tests,
+        Meeting.tests,
         MLS.tests,
         Group.tests,
         Routes.Version.tests,
diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal
index 6d607ffedf1..73f45dd5eb2 100644
--- a/libs/wire-api/wire-api.cabal
+++ b/libs/wire-api/wire-api.cabal
@@ -377,6 +377,7 @@ library
     , tinylog
     , transformers
     , types-common               >=0.16
+    , tz
     , unordered-containers       >=0.2
     , uri-bytestring             >=0.2
     , utf8-string
@@ -713,6 +714,7 @@ test-suite wire-api-tests
     Paths_wire_api
     Test.Wire.API.Call.Config
     Test.Wire.API.Conversation
+    Test.Wire.API.Meeting
     Test.Wire.API.MLS
     Test.Wire.API.MLS.Group
     Test.Wire.API.OAuth
diff --git a/libs/wire-subsystems/postgres-migrations/20260729120000-meetings-tzid.sql b/libs/wire-subsystems/postgres-migrations/20260729120000-meetings-tzid.sql
new file mode 100644
index 00000000000..581b6a5266c
--- /dev/null
+++ b/libs/wire-subsystems/postgres-migrations/20260729120000-meetings-tzid.sql
@@ -0,0 +1 @@
+ALTER TABLE meetings ADD COLUMN tzid text;
diff --git a/libs/wire-subsystems/postgres-migrations/20260729120500-meetings-tzid-notnull.sql b/libs/wire-subsystems/postgres-migrations/20260729120500-meetings-tzid-notnull.sql
new file mode 100644
index 00000000000..e5f92aa399f
--- /dev/null
+++ b/libs/wire-subsystems/postgres-migrations/20260729120500-meetings-tzid-notnull.sql
@@ -0,0 +1,6 @@
+-- Backfill the (nullable) meetings.tzid column added by the preceding migration
+-- with the default legacy time zone, then make it NOT NULL. There is no column
+-- DEFAULT: the application always supplies the value on create (V17) or via the
+-- galley meetings.legacyTimeZone config on the legacy (< V17) create path.
+UPDATE meetings SET tzid = 'Europe/Berlin' WHERE tzid IS NULL;
+ALTER TABLE meetings ALTER COLUMN tzid SET NOT NULL;
diff --git a/libs/wire-subsystems/src/Wire/MeetingsStore.hs b/libs/wire-subsystems/src/Wire/MeetingsStore.hs
index 8b6faec2526..4a4ef8b9476 100644
--- a/libs/wire-subsystems/src/Wire/MeetingsStore.hs
+++ b/libs/wire-subsystems/src/Wire/MeetingsStore.hs
@@ -29,7 +29,7 @@ import Data.Vector (Vector)
 import Data.Vector qualified as V
 import Imports
 import Polysemy
-import Wire.API.Meeting (Recurrence (..))
+import Wire.API.Meeting (Recurrence (..), TimeZone, parseTimeZone, renderTimeZone)
 import Wire.API.PostgresMarshall
 import Wire.API.User.EmailAddress (emailAddressText, fromEmail)
 import Wire.API.User.Identity (EmailAddress)
@@ -43,8 +43,11 @@ data StoredMeeting = StoredMeeting
     creator :: UserId,
     -- | start time of the meeting
     startTime :: UTCTime,
-    -- | end time of the meeting
+    -- | end time of the meeting (the indexed effective-end column)
     endTime :: UTCTime,
+    -- | IANA time zone identifier of the meeting (NOT NULL; backfilled to
+    -- 'defaultLegacyTimeZone' and always supplied on create)
+    tzid :: TimeZone,
     -- | optional recurrence pattern
     recurrence :: Maybe Recurrence,
     -- | conversation where the meeting belongs
@@ -62,8 +65,8 @@ data StoredMeeting = StoredMeeting
 
 -- | Effective end time of a meeting for expiry and cleanup decisions.
 --
--- * No recurrence: the meeting's 'endTime'.
--- * Bounded recurrence ('until' set): 'max endTime until' -- the meeting is
+-- * No recurrence: end_time.
+-- * Bounded recurrence ('until' set): @max end_time until@ -- the meeting is
 --   still alive while its recurrence window is open, even if the original
 --   time slot has passed.
 -- * Open-ended recurrence ('until' = 'Nothing'): 'Nothing' -- the meeting
@@ -80,6 +83,7 @@ type StoredMeetingTuple =
     UUID, -- creator
     UTCTime, -- start_time
     UTCTime, -- end_time
+    Text, -- tzid
     Maybe Text, -- recurrence_frequency
     Maybe Int32, -- recurrence_interval
     Maybe UTCTime, -- recurrence_until
@@ -98,6 +102,7 @@ instance PostgresMarshall StoredMeetingTuple StoredMeeting where
           toUUID storedMeeting.creator,
           storedMeeting.startTime,
           storedMeeting.endTime,
+          renderTimeZone storedMeeting.tzid,
           rFreq,
           rInterval,
           rUntil,
@@ -115,6 +120,7 @@ instance PostgresUnmarshall StoredMeetingTuple StoredMeeting where
       creator',
       startTime',
       endTime',
+      tzid',
       rFreq,
       rInterval,
       rUntil,
@@ -126,6 +132,7 @@ instance PostgresUnmarshall StoredMeetingTuple StoredMeeting where
       ) = do
       rTitle <- first T.pack $ checkedEither title'
       recurrence' <- postgresUnmarshall (rFreq, rInterval, rUntil)
+      tzid'' <- maybe (Left "invalid tzid") Right (parseTimeZone tzid')
       pure
         StoredMeeting
           { id = Id id',
@@ -133,6 +140,7 @@ instance PostgresUnmarshall StoredMeetingTuple StoredMeeting where
             creator = Id creator',
             startTime = startTime',
             endTime = endTime',
+            tzid = tzid'',
             recurrence = recurrence',
             conversationId = Id conversationId',
             invitedEmails = mapMaybe emailAddressText (V.toList invitedEmails'),
@@ -147,6 +155,7 @@ data MeetingsStore m a where
     UserId ->
     UTCTime ->
     UTCTime ->
+    TimeZone ->
     Maybe Recurrence ->
     ConvId ->
     [EmailAddress] ->
diff --git a/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs
index 7188a2309f0..5282c4cddc6 100644
--- a/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs
+++ b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs
@@ -36,7 +36,7 @@ import Hasql.Statement
 import Hasql.TH
 import Imports
 import Polysemy
-import Wire.API.Meeting (Recurrence)
+import Wire.API.Meeting (Recurrence, TimeZone)
 import Wire.API.PostgresMarshall (PostgresMarshall (..), PostgresUnmarshall (..), dimapPG)
 import Wire.API.User.Identity (EmailAddress, fromEmail)
 import Wire.MeetingsStore
@@ -47,10 +47,10 @@ interpretMeetingsStoreToPostgres ::
   InterpreterFor MeetingsStore r
 interpretMeetingsStoreToPostgres =
   interpret $ \case
-    CreateMeeting title creator startTime endTime recurrence convId emails trial ->
-      createMeetingImpl title creator startTime endTime recurrence convId emails trial
-    UpdateMeeting meetingId title startDate endDate schedule ->
-      updateMeetingImpl meetingId title startDate endDate schedule
+    CreateMeeting title creator startTime endTime tzid recurrence convId emails trial ->
+      createMeetingImpl title creator startTime endTime tzid recurrence convId emails trial
+    UpdateMeeting meetingId title startDate endTime schedule ->
+      updateMeetingImpl meetingId title startDate endTime schedule
     DeleteMeeting meetingId ->
       deleteMeetingImpl meetingId
     GetMeeting meetingId ->
@@ -76,12 +76,13 @@ createMeetingImpl ::
   UserId ->
   UTCTime ->
   UTCTime ->
+  TimeZone ->
   Maybe Recurrence ->
   ConvId ->
   [EmailAddress] ->
   Bool ->
   Sem r StoredMeeting
-createMeetingImpl title creator startTime endTime recurrence convId emails trial = do
+createMeetingImpl title creator startTime endTime tzid recurrence convId emails trial = do
   now <- liftIO getCurrentTime
   let sm =
         StoredMeeting
@@ -90,6 +91,7 @@ createMeetingImpl title creator startTime endTime recurrence convId emails trial
             creator = creator,
             startTime = startTime,
             endTime = endTime,
+            tzid = tzid,
             recurrence = recurrence,
             conversationId = convId,
             invitedEmails = emails,
@@ -106,23 +108,23 @@ insertStatement =
       (postgresUnmarshall @StoredMeetingTuple @StoredMeeting)
       [singletonStatement|
         INSERT INTO meetings
-        (title, creator, start_time, end_time,
+        (title, creator, start_time, end_time, tzid,
          recurrence_frequency, recurrence_interval, recurrence_until,
          conversation_id, invited_emails, trial, created_at, updated_at)
         VALUES
-        ($1 :: text, $2 :: uuid, $3 :: timestamptz, $4 :: timestamptz,
-         $5 :: text? :: recurrence_frequency, $6 :: int4?, $7 :: timestamptz?,
-         $8 :: uuid, $9 :: text[], $10 :: boolean, $11 :: timestamptz, $12 :: timestamptz)
+        ($1 :: text, $2 :: uuid, $3 :: timestamptz, $4 :: timestamptz, $5 :: text,
+         $6 :: text? :: recurrence_frequency, $7 :: int4?, $8 :: timestamptz?,
+         $9 :: uuid, $10 :: text[], $11 :: boolean, $12 :: timestamptz, $13 :: timestamptz)
         RETURNING
           id :: uuid, title :: text, creator :: uuid,
-          start_time :: timestamptz, end_time :: timestamptz,
+          start_time :: timestamptz, end_time :: timestamptz, tzid :: text,
           recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?,
           conversation_id :: uuid, invited_emails :: text[], trial :: boolean,
           created_at :: timestamptz, updated_at :: timestamptz
       |]
   where
-    tupleWithoutId (_, t, c, st, et, rf, ri, ru, ci, ie, tr, ca, ua) =
-      (t, c, st, et, rf, ri, ru, ci, ie, tr, ca, ua)
+    tupleWithoutId (_, t, c, st, et, tz, rf, ri, ru, ci, ie, tr, ca, ua) =
+      (t, c, st, et, tz, rf, ri, ru, ci, ie, tr, ca, ua)
 
 -- * Update
 
@@ -186,12 +188,12 @@ updateMeetingImpl ::
   Maybe UTCTime ->
   Maybe (Maybe Recurrence) ->
   Sem r (Maybe StoredMeeting)
-updateMeetingImpl meetingId mTitle mStartDate mEndDate mRecurrence = do
+updateMeetingImpl meetingId mTitle mStartDate mEndTime mRecurrence = do
   case mRecurrence of
     Nothing ->
-      runStatement (mTitle, mStartDate, mEndDate, meetingId) updateWithoutRecurrenceStatement
+      runStatement (mTitle, mStartDate, mEndTime, meetingId) updateWithoutRecurrenceStatement
     Just recurrence ->
-      runStatement (mTitle, mStartDate, mEndDate, recurrence, meetingId) updateWithRecurrenceStatement
+      runStatement (mTitle, mStartDate, mEndTime, recurrence, meetingId) updateWithRecurrenceStatement
   where
     updateWithRecurrenceStatement :: Statement UpdateMeetingWithRecurrenceTuple (Maybe StoredMeeting)
     updateWithRecurrenceStatement =
@@ -212,7 +214,7 @@ updateMeetingImpl meetingId mTitle mStartDate mEndDate mRecurrence = do
           WHERE id = ($7 :: uuid)
           RETURNING
             id :: uuid, title :: text, creator :: uuid,
-            start_time :: timestamptz, end_time :: timestamptz,
+            start_time :: timestamptz, end_time :: timestamptz, tzid :: text,
             recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?,
             conversation_id :: uuid, invited_emails :: text[], trial :: boolean,
             created_at :: timestamptz, updated_at :: timestamptz
@@ -234,7 +236,7 @@ updateMeetingImpl meetingId mTitle mStartDate mEndDate mRecurrence = do
           WHERE id = ($4 :: uuid)
           RETURNING
             id :: uuid, title :: text, creator :: uuid,
-            start_time :: timestamptz, end_time :: timestamptz,
+            start_time :: timestamptz, end_time :: timestamptz, tzid :: text,
             recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?,
             conversation_id :: uuid, invited_emails :: text[], trial :: boolean,
             created_at :: timestamptz, updated_at :: timestamptz
@@ -272,7 +274,7 @@ getMeetingStatement =
     [maybeStatement|
       SELECT
         id :: uuid, title :: text, creator :: uuid,
-        start_time :: timestamptz, end_time :: timestamptz,
+        start_time :: timestamptz, end_time :: timestamptz, tzid :: text,
         recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?,
         conversation_id :: uuid, invited_emails :: text[], trial :: boolean,
         created_at :: timestamptz, updated_at :: timestamptz
@@ -297,7 +299,7 @@ listMeetingsByUserImpl userId cutoffTime = do
         $ [vectorStatement|
           SELECT
             id :: uuid, title :: text, creator :: uuid,
-            start_time :: timestamptz, end_time :: timestamptz,
+            start_time :: timestamptz, end_time :: timestamptz, tzid :: text,
             recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?,
             conversation_id :: uuid, invited_emails :: text[], trial :: boolean,
             created_at :: timestamptz, updated_at :: timestamptz
@@ -329,7 +331,7 @@ listMeetingsByConversationImpl convId cutoffTime = do
         $ [vectorStatement|
           SELECT
             id :: uuid, title :: text, creator :: uuid,
-            start_time :: timestamptz, end_time :: timestamptz,
+            start_time :: timestamptz, end_time :: timestamptz, tzid :: text,
             recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?,
             conversation_id :: uuid, invited_emails :: text[], trial :: boolean,
             created_at :: timestamptz, updated_at :: timestamptz
@@ -409,7 +411,8 @@ getOldMeetingsImpl cutoffTime batchSize = do
     session :: Session [StoredMeeting]
     session = do
       -- Two separate queries so each branch can use its dedicated partial index:
-      --   * non-recurring  -> idx_meetings_end_time_nonrecurring (end_time)
+      --   * non-recurring  -> idx_meetings_end_time_nonrecurring
+      --                        (end_time)
       --   * recurring      -> idx_meetings_recurrence_eff_end
       --                        (GREATEST(end_time, recurrence_until))
       -- A single OR query would match neither partial index and force a scan.
@@ -426,7 +429,7 @@ getOldMeetingsImpl cutoffTime batchSize = do
         $ [vectorStatement|
           SELECT
             id :: uuid, title :: text, creator :: uuid,
-            start_time :: timestamptz, end_time :: timestamptz,
+            start_time :: timestamptz, end_time :: timestamptz, tzid :: text,
             recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?,
             conversation_id :: uuid, invited_emails :: text[], trial :: boolean,
             created_at :: timestamptz, updated_at :: timestamptz
@@ -443,7 +446,7 @@ getOldMeetingsImpl cutoffTime batchSize = do
         $ [vectorStatement|
           SELECT
             id :: uuid, title :: text, creator :: uuid,
-            start_time :: timestamptz, end_time :: timestamptz,
+            start_time :: timestamptz, end_time :: timestamptz, tzid :: text,
             recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?,
             conversation_id :: uuid, invited_emails :: text[], trial :: boolean,
             created_at :: timestamptz, updated_at :: timestamptz
diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs
index de92cab728b..f7731d7cdff 100644
--- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs
+++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs
@@ -51,6 +51,24 @@ data MeetingsSubsystem m a where
   ListMeetings ::
     Local UserId ->
     MeetingsSubsystem m [Meeting]
+  CreateMeetingV16 ::
+    Local UserId ->
+    ConnId ->
+    NewMeetingV16 ->
+    MeetingsSubsystem m MeetingWithConversationV16
+  UpdateMeetingV16 ::
+    Local UserId ->
+    ConnId ->
+    Qualified MeetingId ->
+    UpdateMeetingV16 ->
+    MeetingsSubsystem m (Maybe MeetingWithConversationV16)
+  GetMeetingV16 ::
+    Local UserId ->
+    Qualified MeetingId ->
+    MeetingsSubsystem m (Maybe MeetingV16)
+  ListMeetingsV16 ::
+    Local UserId ->
+    MeetingsSubsystem m [MeetingV16]
   AddInvitedEmails ::
     Local UserId ->
     Qualified MeetingId ->
diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
index 0f6e0adc0d3..2cd32945ecc 100644
--- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
+++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
@@ -111,9 +111,10 @@ interpretMeetingsSubsystem ::
     Member (Error MeetingError) r,
     Member (Input (Local ())) r
   ) =>
+  API.TimeZone ->
   NominalDiffTime ->
   InterpreterFor MeetingsSubsystem r
-interpretMeetingsSubsystem validityPeriod = interpret $ \case
+interpretMeetingsSubsystem legacyTz validityPeriod = interpret $ \case
   CreateMeeting zUser connId newMeeting ->
     createMeetingImpl zUser connId newMeeting
   UpdateMeeting zUser connId meetingId update ->
@@ -124,6 +125,14 @@ interpretMeetingsSubsystem validityPeriod = interpret $ \case
     getMeetingImpl zUser meetingId validityPeriod
   ListMeetings zUser ->
     listMeetingsImpl zUser validityPeriod
+  CreateMeetingV16 zUser connId newMeeting ->
+    API.toLegacyWithConv <$> createMeetingImpl zUser connId (API.fromLegacyNewMeeting legacyTz newMeeting)
+  UpdateMeetingV16 zUser connId meetingId update ->
+    updateMeetingV16Impl zUser connId meetingId update validityPeriod
+  GetMeetingV16 zUser meetingId ->
+    fmap API.toLegacy <$> getMeetingImpl zUser meetingId validityPeriod
+  ListMeetingsV16 zUser ->
+    map API.toLegacy <$> listMeetingsImpl zUser validityPeriod
   AddInvitedEmails zUser meetingId emails ->
     addInvitedEmailsImpl zUser meetingId emails validityPeriod
   RemoveInvitedEmails zUser meetingId emails ->
@@ -150,7 +159,8 @@ createMeetingImpl zUser connId newMeeting = do
   -- Look up user's team once and reuse for both checks
   conversationTeamId <- TeamSubsystem.internalGetOneUserTeam (tUnqualified zUser)
   checkMeetingsEnabled conversationTeamId
-  -- Validate that endTime > startTime
+  -- Validate that the meeting ends after it starts (end_time is the source of
+  -- truth; positivity was previously checked on the derived duration).
   when (newMeeting.endTime <= newMeeting.startTime) $
     throw InvalidTimes
   -- Validate that startTime is not in the past (within tolerance)
@@ -196,6 +206,7 @@ createMeetingImpl zUser connId newMeeting = do
       (tUnqualified zUser)
       newMeeting.startTime
       newMeeting.endTime
+      newMeeting.tzid
       newMeeting.recurrence
       storedConv.id_
       newMeeting.invitedEmails
@@ -234,13 +245,16 @@ updateMeetingImpl zUser connId meetingId update validityPeriod = do
     let cutoff = addUTCTime (negate validityPeriod) now
     guard $ isAlive cutoff meeting
     guard $ qDomain meetingId == tDomain zUser
-    when (fromMaybe meeting.startTime update.startTime >= fromMaybe meeting.endTime update.endTime) $
+    let effStart = fromMaybe meeting.startTime update.startTime
+        mEndTime = update.endTime
+        effEnd = fromMaybe meeting.endTime mEndTime
+    when (effEnd <= effStart) $
       lift $
         throw InvalidTimes
     -- Reject moving the start time into the past, but only while the meeting is
     -- still upcoming. A meeting that has already started may still be edited --
     -- its start time is naturally in the past -- so clients can keep updating an
-    -- ongoing meeting (title, end time, recurrence, or even the start time)
+    -- ongoing meeting (title, end_time, recurrence, or even the start time)
     -- without being blocked by the past-start check (WPB-27465).
     let pastCutoff = addUTCTime (negate startTimeTolerance) now
     for_ update.startTime $ \t ->
@@ -255,12 +269,35 @@ updateMeetingImpl zUser connId meetingId update validityPeriod = do
           (qUnqualified meetingId)
           update.title
           update.startTime
-          update.endTime
+          mEndTime
           update.recurrence
     conv <- MaybeT $ getMeetingConversationOrFail meetingId updatedMeeting.conversationId
     lift $ notifyMeetingEvent zUser (Just connId) conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId MeetingEvent.Update meetingId
     pure $ storedMeetingToMeetingWithConversation zUser conv updatedMeeting
 
+-- | V16 update path: 'API.UpdateMeetingV16' is now 'API.UpdateMeeting' (both
+-- carry an optional @end_time@), so this delegates straight through to the
+-- shared update implementation and re-shapes the result. @tzid@ is immutable,
+-- so the legacy time zone is not needed here.
+updateMeetingV16Impl ::
+  ( Member Store.MeetingsStore r,
+    Member ConversationSubsystem r,
+    Member TeamSubsystem r,
+    Member FeaturesConfigSubsystem r,
+    Member MeetingNotifier r,
+    Member TinyLog r,
+    Member (Error MeetingError) r,
+    Member Now r
+  ) =>
+  Local UserId ->
+  ConnId ->
+  Qualified MeetingId ->
+  API.UpdateMeetingV16 ->
+  NominalDiffTime ->
+  Sem r (Maybe API.MeetingWithConversationV16)
+updateMeetingV16Impl zUser connId meetingId updateL validityPeriod =
+  fmap API.toLegacyWithConv <$> updateMeetingImpl zUser connId meetingId updateL validityPeriod
+
 deleteMeetingImpl ::
   ( Member Store.MeetingsStore r,
     Member ConversationSubsystem r,
@@ -361,6 +398,7 @@ storedMeetingToMeeting domain sm =
       API.creator = Qualified sm.creator domain,
       API.startTime = sm.startTime,
       API.endTime = sm.endTime,
+      API.tzid = sm.tzid,
       API.recurrence = sm.recurrence,
       API.conversationId = Qualified sm.conversationId domain,
       API.invitedEmails = sm.invitedEmails,
diff --git a/libs/wire-subsystems/src/Wire/Options/Galley.hs b/libs/wire-subsystems/src/Wire/Options/Galley.hs
index 377fa557f51..ea30fcdab92 100644
--- a/libs/wire-subsystems/src/Wire/Options/Galley.hs
+++ b/libs/wire-subsystems/src/Wire/Options/Galley.hs
@@ -61,6 +61,7 @@ module Wire.Options.Galley
     checkGroupInfo,
     meetings,
     validityPeriod,
+    legacyTimeZone,
     email,
     MeetingsEmailConfig (..),
     postgresMigration,
@@ -183,7 +184,12 @@ data MeetingsConfig = MeetingsConfig
     _validityPeriod :: !(Maybe Duration),
     -- | Email sending configuration for meeting invitations. When unset, no
     -- meeting invitation emails are sent.
-    _email :: !(Maybe MeetingsEmailConfig)
+    _email :: !(Maybe MeetingsEmailConfig),
+    -- | Default IANA time zone id (e.g. @"Europe/Berlin"@) injected when legacy
+    -- clients (< V17) create meetings that carry no @tzid@ (V17 adds it).
+    -- Resolved in 'Galley.App' via 'parseTimeZone'; defaults
+    -- to @"Europe/Berlin"@ when unset or invalid.
+    _legacyTimeZone :: !(Maybe Text)
   }
   deriving (Show, Generic)
 
diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingNotifierSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingNotifierSpec.hs
index e78591e5f50..eb5ac1d066e 100644
--- a/libs/wire-subsystems/test/unit/Wire/MeetingNotifierSpec.hs
+++ b/libs/wire-subsystems/test/unit/Wire/MeetingNotifierSpec.hs
@@ -31,6 +31,7 @@ import Polysemy.State
 import Polysemy.TinyLog (TinyLog)
 import Test.Hspec
 import Wire.API.Event.Meeting qualified as MeetingEvent
+import Wire.API.Meeting qualified as API
 import Wire.MeetingNotifier
 import Wire.MeetingNotifier.Interpreter
 import Wire.MeetingsStore qualified as Store
@@ -164,6 +165,7 @@ storedMeeting meetingId convId startTime endTime =
       Store.creator = Id $ read "00000000-0000-0000-0000-000000000001",
       Store.startTime = startTime,
       Store.endTime = endTime,
+      Store.tzid = API.defaultLegacyTimeZone,
       Store.recurrence = Nothing,
       Store.conversationId = convId,
       Store.invitedEmails = [],
diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
index 11e968bfc06..50bb123eba6 100644
--- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
+++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
@@ -30,6 +30,8 @@ import Data.Set qualified as Set
 import Data.Tagged (Tagged)
 import Data.Time.Calendar (fromGregorian)
 import Data.Time.Clock
+import Data.UUID (nil)
+import Data.Vector qualified as V
 import Imports
 import Polysemy
 import Polysemy.Error
@@ -46,6 +48,7 @@ import Wire.API.Error (ErrorS)
 import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound))
 import Wire.API.Event.Meeting qualified as MeetingEvent
 import Wire.API.Meeting qualified as API
+import Wire.API.PostgresMarshall (PostgresUnmarshall (postgresUnmarshall))
 import Wire.API.Team.Feature
 import Wire.API.Team.Member (TeamMember, mkTeamMember)
 import Wire.API.Team.Permission (fullPermissions)
@@ -137,7 +140,7 @@ runTestStack now gen teams configs =
     . inMemoryConversationSubsystemInterpreter
     . inMemoryMeetingsStoreInterpreter
     . interpretMeetingNotifier
-    . interpretMeetingsSubsystem 3600
+    . interpretMeetingsSubsystem API.defaultLegacyTimeZone 3600
 
 -- | Decode all 'Push' payloads that are meeting lifecycle events. Any push that
 -- decodes as a 'MeetingEvent.Event' is one: conversation events use distinct
@@ -158,6 +161,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             { title = fromJust $ checked "Test Meeting",
               startTime = addUTCTime 3600 now,
               endTime = addUTCTime 7200 now,
+              tzid = API.defaultLegacyTimeZone,
               recurrence = Nothing,
               invitedEmails = []
             }
@@ -184,6 +188,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             { title = fromJust $ checked "Access Meeting",
               startTime = addUTCTime 3600 now,
               endTime = addUTCTime 7200 now,
+              tzid = API.defaultLegacyTimeZone,
               recurrence = Nothing,
               invitedEmails = []
             }
@@ -198,7 +203,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
         PrivateAccess `elem` access `shouldBe` True
         InviteAccess `elem` access `shouldBe` True
 
-  it "fails to create a meeting if end time is before start time" $ do
+  it "fails to create a meeting if end_time is not after start_time" $ do
     let now = UTCTime (fromGregorian 2026 1 1) 0
         gen = mkStdGen 42
         uid = Id $ read "00000000-0000-0000-0000-000000000001"
@@ -208,6 +213,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             { title = fromJust $ checked "Invalid Meeting",
               startTime = addUTCTime 3600 now,
               endTime = addUTCTime 3500 now,
+              tzid = API.defaultLegacyTimeZone,
               recurrence = Nothing,
               invitedEmails = []
             }
@@ -225,6 +231,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             { title = fromJust $ checked "Past Meeting",
               startTime = addUTCTime (negate 3600) now,
               endTime = addUTCTime 3600 now,
+              tzid = API.defaultLegacyTimeZone,
               recurrence = Nothing,
               invitedEmails = []
             }
@@ -244,6 +251,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             { title = fromJust $ checked "Boundary Meeting",
               startTime = addUTCTime (negate expectedStartTimeTolerance) now,
               endTime = addUTCTime 3600 now,
+              tzid = API.defaultLegacyTimeZone,
               recurrence = Nothing,
               invitedEmails = []
             }
@@ -262,6 +270,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             { title = fromJust $ checked "Just Past Boundary Meeting",
               startTime = addUTCTime (negate (expectedStartTimeTolerance + 1)) now,
               endTime = addUTCTime 3600 now,
+              tzid = API.defaultLegacyTimeZone,
               recurrence = Nothing,
               invitedEmails = []
             }
@@ -289,6 +298,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Past Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -306,6 +316,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Creator Access Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -325,6 +336,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Member Access Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -347,6 +359,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Unauthorized Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -376,6 +389,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Original Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -386,12 +400,13 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
 
       result `shouldBe` Left EmptyUpdate
 
-    it "throws InvalidTimes when startTime >= endTime" $ do
+    it "throws InvalidTimes when end_time is not after start_time" $ do
       let newMeeting =
             API.NewMeeting
               { title = fromJust $ checked "Original Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -400,8 +415,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
         meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         let update =
               API.UpdateMeeting
-                { startTime = Just (addUTCTime 8000 now),
-                  endTime = Nothing,
+                { startTime = Nothing,
+                  endTime = Just (addUTCTime 3600 now),
                   title = Nothing,
                   recurrence = Nothing
                 }
@@ -415,6 +430,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Original Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -438,6 +454,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Ongoing Meeting",
                 startTime = addUTCTime 100 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -446,8 +463,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
           meeting <- createMeeting zUser1 (ConnId "test-conn") ongoingMeeting
           -- Advance the clock 3000s: startTime (now+100s) is now in the past, so
           -- the meeting has started. It stays editable because isAlive is
-          -- endTime-based and endTime (now+7200s) is still well past the
-          -- alive-cutoff (now+3000s-3600s = now-600s).
+          -- effective-end-based (end_time = now+7200s), still past
+          -- the alive-cutoff (now+3000s-3600s = now-600s).
           passTime 3000
           let update =
                 API.UpdateMeeting
@@ -471,6 +488,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Ongoing Meeting",
                 startTime = addUTCTime 100 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -487,13 +505,13 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
                   }
           updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update
       result `shouldBe` Left InvalidTimes
-
     it "returns Nothing for expired meeting" $ do
       let newMeeting =
             API.NewMeeting
               { title = fromJust $ checked "Expired Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -511,6 +529,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Non-creator Update",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -527,6 +546,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Orphaned Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -545,6 +565,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Original Title",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -558,9 +579,9 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               update.title
               update.recurrence
           effectiveStart = fromMaybe baseMeeting.startTime sanitizedUpdate.startTime
-          effectiveEnd = fromMaybe baseMeeting.endTime sanitizedUpdate.endTime
+          effectiveEndTime = fromMaybe baseMeeting.endTime sanitizedUpdate.endTime
           isNotEmpty = sanitizedUpdate /= API.UpdateMeeting Nothing Nothing Nothing Nothing
-          hasValidTimes = effectiveStart < effectiveEnd
+          hasValidTimes = effectiveEndTime > effectiveStart
        in isNotEmpty && hasValidTimes ==>
             ioProperty $ do
               result <- runTestStack now gen Map.empty teamConfig $ do
@@ -576,7 +597,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
                   pure $
                     m.meeting.title === fromMaybe baseMeeting.title sanitizedUpdate.title
                       .&&. m.meeting.startTime === effectiveStart
-                      .&&. m.meeting.endTime === effectiveEnd
+                      .&&. m.meeting.endTime === effectiveEndTime
                       .&&. m.meeting.recurrence === fromMaybe baseMeeting.recurrence sanitizedUpdate.recurrence
                       .&&. m.meeting.conversationId === convId
 
@@ -600,6 +621,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Meeting to Delete",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -618,6 +640,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Meeting to Delete",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -634,6 +657,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Expired Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -659,6 +683,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Meeting to Delete",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -677,6 +702,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Meeting to Delete",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -710,6 +736,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Test Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -733,6 +760,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Expired Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -750,6 +778,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Non-creator Test",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = []
               }
@@ -791,6 +820,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Test Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = [email1, email2, email3]
               }
@@ -814,6 +844,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Test Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = [email1, email2]
               }
@@ -837,6 +868,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Test Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = [email1]
               }
@@ -860,6 +892,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Expired Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = [email1]
               }
@@ -877,6 +910,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Non-creator Test",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = [email1, email2]
               }
@@ -918,6 +952,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Test Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = [email1, email2]
               }
@@ -941,6 +976,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Test Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = [email1, email2]
               }
@@ -964,6 +1000,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Test Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = [email1, email2]
               }
@@ -987,6 +1024,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Expired Meeting",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = [email1]
               }
@@ -1004,6 +1042,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               { title = fromJust $ checked "Non-creator Test",
                 startTime = addUTCTime 3600 now,
                 endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
                 recurrence = Nothing,
                 invitedEmails = [email1, email2]
               }
@@ -1042,6 +1081,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             { title = fromJust $ checked "Recurring Meeting",
               startTime = addUTCTime 3600 now,
               endTime = addUTCTime 7200 now,
+              tzid = API.defaultLegacyTimeZone,
               recurrence = r,
               invitedEmails = []
             }
@@ -1064,6 +1104,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             { title = fromJust $ checked "Meeting",
               startTime = addUTCTime (endOffset - 3600) now,
               endTime = addUTCTime endOffset now,
+              tzid = API.defaultLegacyTimeZone,
               recurrence = r,
               invitedEmails = []
             }
@@ -1201,7 +1242,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
               API.NewMeeting
                 { title = fromJust $ checked "Recurring Meeting",
                   startTime = startTime,
-                  endTime = endTime,
+                  endTime = addUTCTime 3600 startTime,
+                  tzid = API.defaultLegacyTimeZone,
                   recurrence = recurrence,
                   invitedEmails = []
                 }
@@ -1246,6 +1288,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             { title = fromJust $ checked "Test Meeting",
               startTime = addUTCTime 3600 now,
               endTime = addUTCTime 7200 now,
+              tzid = API.defaultLegacyTimeZone,
               recurrence = Nothing,
               invitedEmails = []
             }
@@ -1379,6 +1422,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             { title = fromJust $ checked "Event Test Meeting",
               startTime = addUTCTime 3600 now,
               endTime = addUTCTime 7200 now,
+              tzid = API.defaultLegacyTimeZone,
               recurrence = Nothing,
               invitedEmails = []
             }
@@ -1476,6 +1520,139 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
           map (.recipientUserId) push.recipients `shouldBe` [uid1, uid2]
           push.conn `shouldBe` Just originConn
 
+  describe "V16 operations" $ do
+    let now = UTCTime (fromGregorian 2026 1 1) 0
+        gen = mkStdGen 42
+        uid = Id $ read "00000000-0000-0000-0000-000000000001"
+        zUser = toLocalUnsafe (Domain "wire.com") uid
+        startT = addUTCTime 3600 now
+        endT = addUTCTime 7200 now
+        newLegacy =
+          API.NewMeetingV16
+            { startTime = startT,
+              endTime = endT,
+              recurrence = Nothing,
+              title = fromJust $ checked "Legacy Meeting",
+              invitedEmails = []
+            }
+
+    it "createMeetingV16 then getMeetingV16 returns the requested end_time" $ do
+      result <-
+        runTestStack now gen Map.empty def $ do
+          mwc <- createMeetingV16 zUser (ConnId "test-conn") newLegacy
+          getMeetingV16 zUser mwc.meeting.id
+      case result of
+        Left err -> fail $ "Error: " <> show err
+        Right (Just lm) -> lm.endTime `shouldBe` endT
+        Right Nothing -> fail "expected a meeting"
+
+    it "a meeting created via the new shape is visible to legacy clients with the equivalent end_time" $ do
+      let nm =
+            API.NewMeeting
+              { title = fromJust $ checked "New Meeting",
+                startTime = startT,
+                endTime = addUTCTime 3600 startT,
+                tzid = API.defaultLegacyTimeZone,
+                recurrence = Nothing,
+                invitedEmails = []
+              }
+      result <-
+        runTestStack now gen Map.empty def $ do
+          mwc <- createMeeting zUser (ConnId "test-conn") nm
+          getMeetingV16 zUser mwc.meeting.id
+      case result of
+        Left err -> fail $ "Error: " <> show err
+        Right (Just lm) -> lm.endTime `shouldBe` endT
+        Right Nothing -> fail "expected a meeting"
+
+    it "a meeting created via the legacy shape is visible to new clients with end_time and the default tzid" $ do
+      result <-
+        runTestStack now gen Map.empty def $ do
+          mwc <- createMeetingV16 zUser (ConnId "test-conn") newLegacy
+          getMeeting zUser mwc.meeting.id
+      case result of
+        Left err -> fail $ "Error: " <> show err
+        Right (Just m) -> do
+          m.endTime `shouldBe` endT
+          m.tzid `shouldBe` API.defaultLegacyTimeZone
+        Right Nothing -> fail "expected a meeting"
+
+    it "updateMeetingV16 moving both start and end keeps the requested end_time" $ do
+      let newStart = addUTCTime 8000 now
+          newEnd = addUTCTime 9000 now
+          upd =
+            API.UpdateMeeting
+              { startTime = Just newStart,
+                endTime = Just newEnd,
+                title = Nothing,
+                recurrence = Nothing
+              }
+      result <-
+        runTestStack now gen Map.empty def $ do
+          mwc <- createMeetingV16 zUser (ConnId "test-conn") newLegacy
+          updateMeetingV16 zUser (ConnId "test-conn") mwc.meeting.id upd
+      case result of
+        Left err -> fail $ "Error: " <> show err
+        Right (Just mwc') -> mwc'.meeting.endTime `shouldBe` newEnd
+        Right Nothing -> fail "expected the update to apply"
+
+  describe "StoredMeeting postgres unmarshall" $ do
+    -- A backfilled row (tzid NOT NULL, end_time set): end_time is the truth
+    -- and tzid is read straight off the column.
+    it "parses end_time and tzid for a backfilled row" $ do
+      let t0 = UTCTime (fromGregorian 2026 1 1) 0
+          t1 = addUTCTime 3600 t0
+          legacyRow :: Store.StoredMeetingTuple
+          legacyRow =
+            ( nil,
+              "t",
+              nil,
+              t0,
+              t1,
+              "Europe/Berlin",
+              Nothing,
+              Nothing,
+              Nothing,
+              nil,
+              V.empty,
+              False,
+              t0,
+              t0
+            )
+      let result :: Either Text Store.StoredMeeting
+          result = postgresUnmarshall legacyRow
+      case result of
+        Left e -> expectationFailure $ "unmarshall failed: " <> show e
+        Right sm -> do
+          sm.endTime `shouldBe` t1
+          sm.tzid `shouldBe` API.defaultLegacyTimeZone
+
+    it "rejects a non-null but unparseable tzid instead of swallowing it" $ do
+      let t0 = UTCTime (fromGregorian 2026 1 1) 0
+          t1 = addUTCTime 3600 t0
+          badTzRow :: Store.StoredMeetingTuple
+          badTzRow =
+            ( nil,
+              "t",
+              nil,
+              t0,
+              t1,
+              "not-a-zone",
+              Nothing,
+              Nothing,
+              Nothing,
+              nil,
+              V.empty,
+              False,
+              t0,
+              t0
+            )
+      let result :: Either Text Store.StoredMeeting
+          result = postgresUnmarshall badTzRow
+      case result of
+        Left msg -> msg `shouldBe` ("invalid tzid" :: Text)
+        Right _ -> expectationFailure "expected unmarshall to reject an invalid tzid"
+
 -- | Synchronize with 'Wire.MeetingsSubsystem.Interpreter.startTimeTolerance'
 expectedStartTimeTolerance :: NominalDiffTime
 expectedStartTimeTolerance = 60
diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs
index 6f2b59d55d9..6f4a358b4a3 100644
--- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs
+++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs
@@ -33,7 +33,7 @@ inMemoryMeetingsStoreInterpreter ::
   (Member (State (Map MeetingId StoredMeeting)) r, Member Now r, Member Random r) =>
   InterpreterFor MeetingsStore r
 inMemoryMeetingsStoreInterpreter = interpret $ \case
-  CreateMeeting title creator startTime endTime recurrence conversationId invitedEmails trial -> do
+  CreateMeeting title creator startTime endTime tzid recurrence conversationId invitedEmails trial -> do
     mid <- Random.newId
     now <- Now.get
     let sm =
@@ -43,6 +43,7 @@ inMemoryMeetingsStoreInterpreter = interpret $ \case
               creator = creator,
               startTime = startTime,
               endTime = endTime,
+              tzid = tzid,
               recurrence = recurrence,
               conversationId = conversationId,
               invitedEmails = invitedEmails,
@@ -62,11 +63,13 @@ inMemoryMeetingsStoreInterpreter = interpret $ \case
         let updatedMeeting =
               meeting
                 { title = fromMaybe (meeting.title) title,
-                  startTime = fromMaybe meeting.startTime startTime,
+                  startTime = startTime',
                   endTime = fromMaybe meeting.endTime endTime,
                   recurrence = fromMaybe meeting.recurrence recurrence,
                   updatedAt = now
                 }
+              where
+                startTime' = fromMaybe meeting.startTime startTime
         modify (Map.insert mid updatedMeeting) >> pure (Just updatedMeeting)
   ListMeetingsByUser userId cutoffTime ->
     gets $
diff --git a/postgres-schema.sql b/postgres-schema.sql
index 948f1d62594..c2beceeb205 100644
--- a/postgres-schema.sql
+++ b/postgres-schema.sql
@@ -303,6 +303,7 @@ CREATE TABLE public.meetings (
     trial boolean DEFAULT false NOT NULL,
     created_at timestamp with time zone DEFAULT now() NOT NULL,
     updated_at timestamp with time zone DEFAULT now() NOT NULL,
+    tzid text NOT NULL,
     CONSTRAINT meetings_recurrence_consistency CHECK ((((recurrence_frequency IS NULL) AND (recurrence_interval IS NULL) AND (recurrence_until IS NULL)) OR ((recurrence_frequency IS NOT NULL) AND (recurrence_interval IS NOT NULL)))),
     CONSTRAINT meetings_title_length CHECK ((length(title) <= 256)),
     CONSTRAINT meetings_title_not_empty CHECK ((length(TRIM(BOTH FROM title)) > 0)),
diff --git a/services/background-worker/src/Wire/MeetingsCleanupWorker.hs b/services/background-worker/src/Wire/MeetingsCleanupWorker.hs
index 5b00955373f..710f444abbd 100644
--- a/services/background-worker/src/Wire/MeetingsCleanupWorker.hs
+++ b/services/background-worker/src/Wire/MeetingsCleanupWorker.hs
@@ -29,6 +29,7 @@ import Imports
 import Polysemy.Error (runError)
 import Prometheus (incCounter)
 import System.Logger qualified as Log
+import Wire.API.Meeting (defaultLegacyTimeZone)
 import Wire.BackgroundWorker.Env (AppT, Env (..), MeetingsCleanupMetrics (..))
 import Wire.Effects
 import Wire.ExternalAccess.External
@@ -101,7 +102,7 @@ runMeetingsCleanup env cutoffTime validityPeriod batchSize = do
     . runBackgroundWorkerEffects env extEnv (RequestId "meetings-cleanup") Nothing
     . interpretMeetingsStoreToPostgres
     . runError @MeetingError
-    . interpretMeetingsSubsystem validityPeriod
+    . interpretMeetingsSubsystem defaultLegacyTimeZone validityPeriod
     $ Wire.MeetingsSubsystem.cleanupOldMeetings cutoffTime batchSize
 
 data WorkerException = WorkerException Text
diff --git a/services/galley/galley.integration.yaml b/services/galley/galley.integration.yaml
index 47980040c1e..ca817efe7eb 100644
--- a/services/galley/galley.integration.yaml
+++ b/services/galley/galley.integration.yaml
@@ -93,6 +93,7 @@ settings:
 
   meetings:
     validityPeriod: "5s"
+    legacyTimeZone: "Europe/Berlin"
     email:
       from: meetings@integration.example.com
       replyTo: reply@integration.example.com
diff --git a/services/galley/src/Galley/API/Meetings.hs b/services/galley/src/Galley/API/Meetings.hs
index a43955905d2..f8c832acc8d 100644
--- a/services/galley/src/Galley/API/Meetings.hs
+++ b/services/galley/src/Galley/API/Meetings.hs
@@ -21,6 +21,10 @@ module Galley.API.Meetings
     deleteMeeting,
     getMeeting,
     listMeetings,
+    createMeetingV16,
+    updateMeetingV16,
+    getMeetingV16,
+    listMeetingsV16,
     addMeetingInvitation,
     removeMeetingInvitation,
     replaceMeetingInvitation,
@@ -57,10 +61,7 @@ updateMeeting ::
   Sem r MeetingWithConversation
 updateMeeting zUser connId domain meetingId update = do
   let qMeetingId = Qualified meetingId domain
-  maybeMeeting <- Meetings.updateMeeting zUser connId qMeetingId update
-  case maybeMeeting of
-    Nothing -> throwS @'MeetingNotFound
-    Just meeting -> pure meeting
+  noteS @'MeetingNotFound =<< Meetings.updateMeeting zUser connId qMeetingId update
 
 deleteMeeting ::
   ( Member Meetings.MeetingsSubsystem r,
@@ -86,10 +87,7 @@ getMeeting ::
   Sem r Meeting
 getMeeting zUser domain meetingId = do
   let qMeetingId = Qualified meetingId domain
-  maybeMeeting <- Meetings.getMeeting zUser qMeetingId
-  case maybeMeeting of
-    Nothing -> throwS @'MeetingNotFound
-    Just meeting -> pure meeting
+  noteS @'MeetingNotFound =<< Meetings.getMeeting zUser qMeetingId
 
 listMeetings ::
   (Member Meetings.MeetingsSubsystem r) =>
@@ -97,6 +95,46 @@ listMeetings ::
   Sem r [Meeting]
 listMeetings lUser = Meetings.listMeetings lUser
 
+createMeetingV16 ::
+  (Member Meetings.MeetingsSubsystem r) =>
+  Local UserId ->
+  ConnId ->
+  NewMeetingV16 ->
+  Sem r MeetingWithConversationV16
+createMeetingV16 lUser connId newMeeting = Meetings.createMeetingV16 lUser connId newMeeting
+
+updateMeetingV16 ::
+  ( Member Meetings.MeetingsSubsystem r,
+    Member (ErrorS 'MeetingNotFound) r
+  ) =>
+  Local UserId ->
+  ConnId ->
+  Domain ->
+  MeetingId ->
+  UpdateMeetingV16 ->
+  Sem r MeetingWithConversationV16
+updateMeetingV16 zUser connId domain meetingId update = do
+  let qMeetingId = Qualified meetingId domain
+  noteS @'MeetingNotFound =<< Meetings.updateMeetingV16 zUser connId qMeetingId update
+
+getMeetingV16 ::
+  ( Member Meetings.MeetingsSubsystem r,
+    Member (ErrorS 'MeetingNotFound) r
+  ) =>
+  Local UserId ->
+  Domain ->
+  MeetingId ->
+  Sem r MeetingV16
+getMeetingV16 zUser domain meetingId = do
+  let qMeetingId = Qualified meetingId domain
+  noteS @'MeetingNotFound =<< Meetings.getMeetingV16 zUser qMeetingId
+
+listMeetingsV16 ::
+  (Member Meetings.MeetingsSubsystem r) =>
+  Local UserId ->
+  Sem r [MeetingV16]
+listMeetingsV16 lUser = Meetings.listMeetingsV16 lUser
+
 addMeetingInvitation ::
   ( Member Meetings.MeetingsSubsystem r,
     Member (ErrorS 'MeetingNotFound) r
diff --git a/services/galley/src/Galley/API/Public/Meetings.hs b/services/galley/src/Galley/API/Public/Meetings.hs
index a38ca024753..e0eef4c8a47 100644
--- a/services/galley/src/Galley/API/Public/Meetings.hs
+++ b/services/galley/src/Galley/API/Public/Meetings.hs
@@ -24,14 +24,14 @@ import Wire.API.Routes.Public.Galley.Meetings
 
 meetingsAPI :: API MeetingsAPI GalleyEffects
 meetingsAPI =
-  mkNamedAPI @"create-meeting@v15" Meetings.createMeeting
+  mkNamedAPI @"create-meeting@v15" Meetings.createMeetingV16
     <@> mkNamedAPI @"create-meeting" Meetings.createMeeting
-    <@> mkNamedAPI @"update-meeting@v15" Meetings.updateMeeting
+    <@> mkNamedAPI @"update-meeting@v15" Meetings.updateMeetingV16
     <@> mkNamedAPI @"update-meeting" Meetings.updateMeeting
     <@> mkNamedAPI @"delete-meeting" Meetings.deleteMeeting
-    <@> mkNamedAPI @"get-meeting@v15" Meetings.getMeeting
+    <@> mkNamedAPI @"get-meeting@v15" Meetings.getMeetingV16
     <@> mkNamedAPI @"get-meeting" Meetings.getMeeting
-    <@> mkNamedAPI @"list-meetings@v16" Meetings.listMeetings
+    <@> mkNamedAPI @"list-meetings@v16" Meetings.listMeetingsV16
     <@> mkNamedAPI @"list-meetings" Meetings.listMeetings
     <@> mkNamedAPI @"add-meeting-invitation" Meetings.addMeetingInvitation
     <@> mkNamedAPI @"remove-meeting-invitation" Meetings.removeMeetingInvitation
diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs
index bd0131b4d2d..f954f0880f3 100644
--- a/services/galley/src/Galley/App.hs
+++ b/services/galley/src/Galley/App.hs
@@ -90,6 +90,7 @@ import Wire.API.Error.Galley (GalleyError (..), NonFederatingBackends, Operation
 import Wire.API.Federation.Client
 import Wire.API.Federation.Error
 import Wire.API.MLS.Keys (MLSKeysByPurpose, MLSPrivateKeys)
+import Wire.API.Meeting (defaultLegacyTimeZone, parseTimeZone)
 import Wire.API.Team.Collaborator
 import Wire.API.Team.Feature
 import Wire.API.Team.FeatureFlags
@@ -564,10 +565,12 @@ evalGalley e =
             }
         . interpretMeetingNotifier
         . interpretConversationSubsystem
-        . Meeting.interpretMeetingsSubsystem meetingValidityPeriod
+        . Meeting.interpretMeetingsSubsystem meetingLegacyTimeZone meetingValidityPeriod
   where
     meetingValidityPeriod =
       realToFrac $ maybe (48 * 3600) (.duration) (e ^. options . settings . meetings >>= view validityPeriod)
+    meetingLegacyTimeZone =
+      fromMaybe defaultLegacyTimeZone (e ^. options . settings . meetings >>= view legacyTimeZone >>= parseTimeZone)
     lh = view (options . settings . featureFlags . to npProject) e
     legalHoldEnv =
       let makeReq fpr url rb = runApp e (LHInternal.makeVerifiedRequest fpr url rb)

From 86b8ee017ef0ecd22b5b9787a253c78a7645338e Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Wed, 19 Aug 2026 15:43:04 +0200
Subject: [PATCH 094/113] WPB-28080: relax Meeting editing from start time
 (#5451)

---
 .../WPB-28080-meeting-past-edit-period        |   1 +
 .../templates/galley/configmap.yaml           |   3 +
 charts/wire-server/values.yaml                |   1 +
 .../src/developer/reference/config-options.md |  22 ++++
 hack/helm_vars/wire-server/values.yaml.gotmpl |   1 +
 .../src/Wire/MeetingsSubsystem/Interpreter.hs |  79 ++++++++------
 .../src/Wire/Options/Galley.hs                |   4 +
 .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 102 ++++++++++++++++--
 .../src/Wire/MeetingsCleanupWorker.hs         |   5 +-
 services/galley/galley.integration.yaml       |   1 +
 services/galley/src/Galley/App.hs             |  22 +++-
 11 files changed, 194 insertions(+), 47 deletions(-)
 create mode 100644 changelog.d/0-release-notes/WPB-28080-meeting-past-edit-period

diff --git a/changelog.d/0-release-notes/WPB-28080-meeting-past-edit-period b/changelog.d/0-release-notes/WPB-28080-meeting-past-edit-period
new file mode 100644
index 00000000000..44a229af4ef
--- /dev/null
+++ b/changelog.d/0-release-notes/WPB-28080-meeting-past-edit-period
@@ -0,0 +1 @@
+Added a new galley setting `settings.meetings.pastEditPeriod` (default 24h): how far into the past `PUT /meetings/{domain}/{id}` may move a meeting's `start_time`/`end_time`, so past and ongoing meetings can be corrected after the fact. Previously any start time in the past (beyond a 60s tolerance) was rejected while a meeting was still upcoming. Galley refuses to start if `pastEditPeriod` is negative or greater than `settings.meetings.validityPeriod`.
diff --git a/charts/wire-server/templates/galley/configmap.yaml b/charts/wire-server/templates/galley/configmap.yaml
index 528fe4cbd72..5dc3984c6f3 100644
--- a/charts/wire-server/templates/galley/configmap.yaml
+++ b/charts/wire-server/templates/galley/configmap.yaml
@@ -118,6 +118,9 @@ data:
         validityPeriod: {{ .validityPeriod }}
         {{- end }}
         legacyTimeZone: {{ .legacyTimeZone }}
+        {{- if .pastEditPeriod }}
+        pastEditPeriod: {{ .pastEditPeriod }}
+        {{- end }}
         {{- with .email }}
         email:
           from: {{ required "Missing value: galley.config.settings.meetings.email.from" .from | quote }}
diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml
index 7d89ce72b91..494b3a814bf 100644
--- a/charts/wire-server/values.yaml
+++ b/charts/wire-server/values.yaml
@@ -142,6 +142,7 @@ galley:
       meetings:
         validityPeriod: "48h"
         legacyTimeZone: "Europe/Berlin"
+        pastEditPeriod: "24h"
         # Optional. When set, meeting invitation emails are sent with this
         # sender over the configured transport (SES xor SMTP). `useSES` selects
         # the transport; `aws` is used when true, `smtp` when false (mirrors
diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md
index a6ab4af3799..90dd5ad7fd8 100644
--- a/docs/src/developer/reference/config-options.md
+++ b/docs/src/developer/reference/config-options.md
@@ -249,6 +249,28 @@ The lock status for individual teams can be changed via the internal API (`PUT /
 
 The feature status for individual teams can be changed via the public API (if the feature is unlocked).
 
+### Meetings validity and past-edit periods
+
+`settings.meetings.validityPeriod` (default `48h`) is how long a meeting stays
+alive (readable and editable) after its effective end time — its `end_time`, or
+the end of its recurrence window for recurring meetings; open-ended recurring
+meetings never expire. `settings.meetings.pastEditPeriod` (default `24h`) bounds how
+far into the past `PUT /meetings/{domain}/{id}` may move a meeting's
+`start_time`/`end_time`, so past and ongoing meetings can be corrected to what
+actually happened. Only provided time values are checked against this cutoff;
+unchanged stored times are not re-validated — but the effective times (provided
+or stored) must still satisfy `end_time > start_time`. Galley refuses to start
+if `pastEditPeriod` is negative or greater than `validityPeriod`, so a meeting
+edited to past times stays inside the validity window and remains visible and editable.
+
+```yaml
+# galley.yaml
+settings:
+  meetings:
+    validityPeriod: "48h"
+    pastEditPeriod: "24h"
+```
+
 ### Meetings email sender and transport
 
 The optional `settings.meetings.email` block enables emailing meeting
diff --git a/hack/helm_vars/wire-server/values.yaml.gotmpl b/hack/helm_vars/wire-server/values.yaml.gotmpl
index 9478334e701..556e8e1a225 100644
--- a/hack/helm_vars/wire-server/values.yaml.gotmpl
+++ b/hack/helm_vars/wire-server/values.yaml.gotmpl
@@ -318,6 +318,7 @@ galley:
       meetings:
         validityPeriod: "5s"
         legacyTimeZone: "Europe/Berlin"
+        pastEditPeriod: "5s"
         email:
           from: meetings@example.com
           replyTo: noreply@example.com
diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
index 2cd32945ecc..d53ec9d4b88 100644
--- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
+++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
@@ -16,7 +16,8 @@
 -- with this program. If not, see .
 
 module Wire.MeetingsSubsystem.Interpreter
-  ( interpretMeetingsSubsystem,
+  ( MeetingSystemConfig (..),
+    interpretMeetingsSubsystem,
     startTimeTolerance,
     MeetingError (..),
   )
@@ -100,6 +101,17 @@ meetingsFeatureEnabled maybeTeamId =
       meetingFeature <- getFeatureForTeam @_ @MeetingsConfig teamId
       pure (meetingFeature.status == FeatureStatusEnabled)
 
+-- | System-wide meeting configuration: the legacy time zone used for V16
+-- meetings, how long meetings stay alive after their effective end time, and
+-- how far into the past an update may move a meeting's times. Invariant
+-- (checked by callers at startup where configurable): @pastEditPeriod <=
+-- validityPeriod@, so an edited meeting stays within the validity window.
+data MeetingSystemConfig = MeetingSystemConfig
+  { legacyTimeZone :: !API.TimeZone,
+    validityPeriod :: !NominalDiffTime,
+    pastEditPeriod :: !NominalDiffTime
+  }
+
 interpretMeetingsSubsystem ::
   ( Member Store.MeetingsStore r,
     Member ConversationSubsystem r,
@@ -111,34 +123,34 @@ interpretMeetingsSubsystem ::
     Member (Error MeetingError) r,
     Member (Input (Local ())) r
   ) =>
-  API.TimeZone ->
-  NominalDiffTime ->
+  -- | System-wide meeting configuration.
+  MeetingSystemConfig ->
   InterpreterFor MeetingsSubsystem r
-interpretMeetingsSubsystem legacyTz validityPeriod = interpret $ \case
+interpretMeetingsSubsystem cfg = interpret $ \case
   CreateMeeting zUser connId newMeeting ->
     createMeetingImpl zUser connId newMeeting
   UpdateMeeting zUser connId meetingId update ->
-    updateMeetingImpl zUser connId meetingId update validityPeriod
+    updateMeetingImpl zUser connId meetingId update cfg.validityPeriod cfg.pastEditPeriod
   DeleteMeeting zUser connId meetingId ->
-    deleteMeetingImpl zUser connId meetingId validityPeriod
+    deleteMeetingImpl zUser connId meetingId cfg.validityPeriod
   GetMeeting zUser meetingId ->
-    getMeetingImpl zUser meetingId validityPeriod
+    getMeetingImpl zUser meetingId cfg.validityPeriod
   ListMeetings zUser ->
-    listMeetingsImpl zUser validityPeriod
+    listMeetingsImpl zUser cfg.validityPeriod
   CreateMeetingV16 zUser connId newMeeting ->
-    API.toLegacyWithConv <$> createMeetingImpl zUser connId (API.fromLegacyNewMeeting legacyTz newMeeting)
+    API.toLegacyWithConv <$> createMeetingImpl zUser connId (API.fromLegacyNewMeeting cfg.legacyTimeZone newMeeting)
   UpdateMeetingV16 zUser connId meetingId update ->
-    updateMeetingV16Impl zUser connId meetingId update validityPeriod
+    updateMeetingV16Impl zUser connId meetingId update cfg.validityPeriod cfg.pastEditPeriod
   GetMeetingV16 zUser meetingId ->
-    fmap API.toLegacy <$> getMeetingImpl zUser meetingId validityPeriod
+    fmap API.toLegacy <$> getMeetingImpl zUser meetingId cfg.validityPeriod
   ListMeetingsV16 zUser ->
-    map API.toLegacy <$> listMeetingsImpl zUser validityPeriod
+    map API.toLegacy <$> listMeetingsImpl zUser cfg.validityPeriod
   AddInvitedEmails zUser meetingId emails ->
-    addInvitedEmailsImpl zUser meetingId emails validityPeriod
+    addInvitedEmailsImpl zUser meetingId emails cfg.validityPeriod
   RemoveInvitedEmails zUser meetingId emails ->
-    removeInvitedEmailsImpl zUser meetingId emails validityPeriod
+    removeInvitedEmailsImpl zUser meetingId emails cfg.validityPeriod
   ReplaceInvitedEmails zUser meetingId emails ->
-    replaceInvitedEmailsImpl zUser meetingId emails validityPeriod
+    replaceInvitedEmailsImpl zUser meetingId emails cfg.validityPeriod
   CleanupOldMeetings cutoffTime batchSize ->
     cleanupOldMeetingsImpl cutoffTime batchSize
 
@@ -232,8 +244,9 @@ updateMeetingImpl ::
   Qualified MeetingId ->
   API.UpdateMeeting ->
   NominalDiffTime ->
+  NominalDiffTime ->
   Sem r (Maybe API.MeetingWithConversation)
-updateMeetingImpl zUser connId meetingId update validityPeriod = do
+updateMeetingImpl zUser connId meetingId update validityPeriod pastEditPeriod = do
   maybeTeamId <- TeamSubsystem.internalGetOneUserTeam (tUnqualified zUser)
   checkMeetingsEnabled maybeTeamId
   when (isNothing update.title && isNothing update.startTime && isNothing update.endTime && isNothing update.recurrence) $
@@ -245,31 +258,28 @@ updateMeetingImpl zUser connId meetingId update validityPeriod = do
     let cutoff = addUTCTime (negate validityPeriod) now
     guard $ isAlive cutoff meeting
     guard $ qDomain meetingId == tDomain zUser
-    let effStart = fromMaybe meeting.startTime update.startTime
-        mEndTime = update.endTime
-        effEnd = fromMaybe meeting.endTime mEndTime
-    when (effEnd <= effStart) $
+    guard $ meeting.creator == tUnqualified zUser
+    -- Creation enforces endTime > startTime (createMeetingImpl); re-establish
+    -- the invariant on the *effective* times after this update, since either
+    -- bound may change independently.
+    when (fromMaybe meeting.startTime update.startTime >= fromMaybe meeting.endTime update.endTime) $
       lift $
         throw InvalidTimes
-    -- Reject moving the start time into the past, but only while the meeting is
-    -- still upcoming. A meeting that has already started may still be edited --
-    -- its start time is naturally in the past -- so clients can keep updating an
-    -- ongoing meeting (title, end_time, recurrence, or even the start time)
-    -- without being blocked by the past-start check (WPB-27465).
-    let pastCutoff = addUTCTime (negate startTimeTolerance) now
-    for_ update.startTime $ \t ->
-      when (meeting.startTime >= pastCutoff && t < pastCutoff) $
-        lift $
-          throw InvalidTimes
+    -- New time values may be moved into the past so that past/ongoing
+    -- meetings can be corrected to what actually happened, but no further
+    -- than `pastEditPeriod` (WPB-28080). Only provided values are checked;
+    -- unchanged stored times are not re-validated.
+    let pastEditCutoff = addUTCTime (negate pastEditPeriod) now
+    for_ update.startTime $ \t -> when (t < pastEditCutoff) $ lift $ throw InvalidTimes
+    for_ update.endTime $ \t -> when (t < pastEditCutoff) $ lift $ throw InvalidTimes
 
-    guard $ meeting.creator == tUnqualified zUser
     updatedMeeting <-
       MaybeT $
         Store.updateMeeting
           (qUnqualified meetingId)
           update.title
           update.startTime
-          mEndTime
+          update.endTime
           update.recurrence
     conv <- MaybeT $ getMeetingConversationOrFail meetingId updatedMeeting.conversationId
     lift $ notifyMeetingEvent zUser (Just connId) conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId MeetingEvent.Update meetingId
@@ -294,9 +304,10 @@ updateMeetingV16Impl ::
   Qualified MeetingId ->
   API.UpdateMeetingV16 ->
   NominalDiffTime ->
+  NominalDiffTime ->
   Sem r (Maybe API.MeetingWithConversationV16)
-updateMeetingV16Impl zUser connId meetingId updateL validityPeriod =
-  fmap API.toLegacyWithConv <$> updateMeetingImpl zUser connId meetingId updateL validityPeriod
+updateMeetingV16Impl zUser connId meetingId updateL validityPeriod pastEditPeriod =
+  fmap API.toLegacyWithConv <$> updateMeetingImpl zUser connId meetingId updateL validityPeriod pastEditPeriod
 
 deleteMeetingImpl ::
   ( Member Store.MeetingsStore r,
diff --git a/libs/wire-subsystems/src/Wire/Options/Galley.hs b/libs/wire-subsystems/src/Wire/Options/Galley.hs
index ea30fcdab92..0ba9e542641 100644
--- a/libs/wire-subsystems/src/Wire/Options/Galley.hs
+++ b/libs/wire-subsystems/src/Wire/Options/Galley.hs
@@ -62,6 +62,7 @@ module Wire.Options.Galley
     meetings,
     validityPeriod,
     legacyTimeZone,
+    pastEditPeriod,
     email,
     MeetingsEmailConfig (..),
     postgresMigration,
@@ -182,6 +183,9 @@ data Settings = Settings
 data MeetingsConfig = MeetingsConfig
   { -- | Validity period of a meeting. After this time, the meeting is considered expired.
     _validityPeriod :: !(Maybe Duration),
+    -- | How far in the past a meeting's start/end time may be moved by an
+    -- update. Must not exceed 'validityPeriod'.
+    _pastEditPeriod :: !(Maybe Duration),
     -- | Email sending configuration for meeting invitations. When unset, no
     -- meeting invitation emails are sent.
     _email :: !(Maybe MeetingsEmailConfig),
diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
index 50bb123eba6..28eb0dbb2a2 100644
--- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
+++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
@@ -140,7 +140,12 @@ runTestStack now gen teams configs =
     . inMemoryConversationSubsystemInterpreter
     . inMemoryMeetingsStoreInterpreter
     . interpretMeetingNotifier
-    . interpretMeetingsSubsystem API.defaultLegacyTimeZone 3600
+    . interpretMeetingsSubsystem
+      MeetingSystemConfig
+        { legacyTimeZone = API.defaultLegacyTimeZone,
+          validityPeriod = 3600,
+          pastEditPeriod = configuredPastEditPeriod
+        }
 
 -- | Decode all 'Push' payloads that are meeting lifecycle events. Any push that
 -- decodes as a 'MeetingEvent.Event' is one: conversation events use distinct
@@ -424,7 +429,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
 
       result `shouldBe` Left InvalidTimes
 
-    it "throws InvalidTimes when updating startTime to the past" $ do
+    it "allows editing an upcoming meeting's start time into the past within the past-edit window" $ do
       let newMeeting =
             API.NewMeeting
               { title = fromJust $ checked "Original Meeting",
@@ -439,13 +444,91 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
         meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
         let update =
               API.UpdateMeeting
-                { startTime = Just (addUTCTime (negate 3600) now),
+                { startTime = Just (addUTCTime (negate 60) now),
                   endTime = Nothing,
                   title = Nothing,
                   recurrence = Nothing
                 }
         updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update
 
+      case result of
+        Left err -> fail $ "Expected the update to be applied, got: " <> show err
+        Right Nothing -> fail "Expected the update to be applied"
+        Right (Just updated) ->
+          updated.meeting.startTime `shouldBe` addUTCTime (negate 60) now
+
+    it "accepts a meeting update whose start time is exactly at the past-edit boundary" $ do
+      let newMeeting =
+            API.NewMeeting
+              { title = fromJust $ checked "Original Meeting",
+                startTime = addUTCTime 3600 now,
+                endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
+                recurrence = Nothing,
+                invitedEmails = []
+              }
+
+      result <- runTestStack now gen Map.empty teamConfig $ do
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
+        let update =
+              API.UpdateMeeting
+                { startTime = Just (addUTCTime (negate configuredPastEditPeriod) now),
+                  endTime = Nothing,
+                  title = Nothing,
+                  recurrence = Nothing
+                }
+        updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update
+
+      case result of
+        Right (Just _) -> pure ()
+        other -> fail $ "Expected the update to be applied, got: " <> show other
+
+    it "rejects a meeting update whose start time is just past the past-edit boundary" $ do
+      let newMeeting =
+            API.NewMeeting
+              { title = fromJust $ checked "Original Meeting",
+                startTime = addUTCTime 3600 now,
+                endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
+                recurrence = Nothing,
+                invitedEmails = []
+              }
+
+      result <- runTestStack now gen Map.empty teamConfig $ do
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
+        let update =
+              API.UpdateMeeting
+                { startTime = Just (addUTCTime (negate (configuredPastEditPeriod + 1)) now),
+                  endTime = Nothing,
+                  title = Nothing,
+                  recurrence = Nothing
+                }
+        updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update
+
+      result `shouldBe` Left InvalidTimes
+
+    it "throws InvalidTimes when updating both times beyond the past-edit window even when start < end" $ do
+      let newMeeting =
+            API.NewMeeting
+              { title = fromJust $ checked "Original Meeting",
+                startTime = addUTCTime 3600 now,
+                endTime = addUTCTime 7200 now,
+                tzid = API.defaultLegacyTimeZone,
+                recurrence = Nothing,
+                invitedEmails = []
+              }
+
+      result <- runTestStack now gen Map.empty teamConfig $ do
+        meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting
+        let update =
+              API.UpdateMeeting
+                { startTime = Just (addUTCTime (negate 7200) now),
+                  endTime = Just (addUTCTime (negate (configuredPastEditPeriod + 1)) now),
+                  title = Nothing,
+                  recurrence = Nothing
+                }
+        updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update
+
       result `shouldBe` Left InvalidTimes
 
     it "allows editing an already-started (ongoing) meeting, including its start time" $ do
@@ -569,13 +652,14 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
                 recurrence = Nothing,
                 invitedEmails = []
               }
-          -- Clamp the updated start time so it is not in the past (within
-          -- tolerance). This avoids discarding QuickCheck-generated updates
-          -- whose arbitrary UTCTime is far from `now`.
+          -- Clamp the updated start/end times so they are not in the past
+          -- beyond the past-edit window. This avoids discarding
+          -- QuickCheck-generated updates whose arbitrary UTCTime is far from
+          -- `now`.
           sanitizedUpdate =
             API.UpdateMeeting
               (fmap (max (addUTCTime (negate 60) now)) update.startTime)
-              update.endTime
+              (fmap (max (addUTCTime (negate 60) now)) update.endTime)
               update.title
               update.recurrence
           effectiveStart = fromMaybe baseMeeting.startTime sanitizedUpdate.startTime
@@ -1657,6 +1741,10 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
 expectedStartTimeTolerance :: NominalDiffTime
 expectedStartTimeTolerance = 60
 
+-- | Past-edit window configured in 'runTestStack'.
+configuredPastEditPeriod :: NominalDiffTime
+configuredPastEditPeriod = 3600
+
 -- | Validity window, beyond this one-time meeting belong to the past
 validityWindow :: NominalDiffTime
 validityWindow = 11000
diff --git a/services/background-worker/src/Wire/MeetingsCleanupWorker.hs b/services/background-worker/src/Wire/MeetingsCleanupWorker.hs
index 710f444abbd..50dcefc1b48 100644
--- a/services/background-worker/src/Wire/MeetingsCleanupWorker.hs
+++ b/services/background-worker/src/Wire/MeetingsCleanupWorker.hs
@@ -102,7 +102,10 @@ runMeetingsCleanup env cutoffTime validityPeriod batchSize = do
     . runBackgroundWorkerEffects env extEnv (RequestId "meetings-cleanup") Nothing
     . interpretMeetingsStoreToPostgres
     . runError @MeetingError
-    . interpretMeetingsSubsystem defaultLegacyTimeZone validityPeriod
+    -- Cleanup performs no meeting updates, so the past-edit period is
+    -- unused; pass 0 to keep the (inert) limit as tight as possible.
+    . interpretMeetingsSubsystem
+      MeetingSystemConfig {legacyTimeZone = defaultLegacyTimeZone, validityPeriod, pastEditPeriod = 0}
     $ Wire.MeetingsSubsystem.cleanupOldMeetings cutoffTime batchSize
 
 data WorkerException = WorkerException Text
diff --git a/services/galley/galley.integration.yaml b/services/galley/galley.integration.yaml
index ca817efe7eb..85ec6c320c1 100644
--- a/services/galley/galley.integration.yaml
+++ b/services/galley/galley.integration.yaml
@@ -94,6 +94,7 @@ settings:
   meetings:
     validityPeriod: "5s"
     legacyTimeZone: "Europe/Berlin"
+    pastEditPeriod: "5s"
     email:
       from: meetings@integration.example.com
       replyTo: reply@integration.example.com
diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs
index f954f0880f3..e3d6855dd3f 100644
--- a/services/galley/src/Galley/App.hs
+++ b/services/galley/src/Galley/App.hs
@@ -54,6 +54,7 @@ import Data.Misc
 import Data.Qualified
 import Data.Range
 import Data.Text qualified as Text
+import Data.Time.Clock (secondsToDiffTime)
 import Galley.Env
 import Galley.External.LegalHoldService.Internal qualified as LHInternal
 import Galley.Monad (runApp)
@@ -304,6 +305,18 @@ type GalleyEffects =
      Final IO
    ]
 
+-- | Resolved meeting system config, with defaults 48h validity / 24h
+-- past-edit period.
+meetingSystemConfig :: Opts -> Meeting.MeetingSystemConfig
+meetingSystemConfig o =
+  Meeting.MeetingSystemConfig
+    { legacyTimeZone = fromMaybe defaultLegacyTimeZone (m >>= view legacyTimeZone >>= parseTimeZone),
+      validityPeriod = realToFrac $ maybe (secondsToDiffTime (48 * 3600)) (.duration) (m >>= view validityPeriod),
+      pastEditPeriod = realToFrac $ maybe (secondsToDiffTime (24 * 3600)) (.duration) (m >>= view pastEditPeriod)
+    }
+  where
+    m = o ^. settings . meetings
+
 -- Define some invariants for the options used
 validateOptions :: Opts -> IO (Either HttpsUrl (Map Domain HttpsUrl))
 validateOptions o = do
@@ -313,6 +326,9 @@ validateOptions o = do
     error "setMaxConvSize cannot be > setTruncationLimit"
   when (settings' ^. maxTeamSize < optFanoutLimit) $
     error "setMaxTeamSize cannot be < setTruncationLimit"
+  let meetingsCfg = meetingSystemConfig o
+  when (meetingsCfg.pastEditPeriod < 0 || meetingsCfg.pastEditPeriod > meetingsCfg.validityPeriod) $
+    error "settings.meetings.pastEditPeriod must be non-negative and cannot be greater than settings.meetings.validityPeriod"
   case (o ^. O.federator, o ^. rabbitmq) of
     (Nothing, Just _) -> error "RabbitMQ config is specified and federator is not, please specify both or none"
     (Just _, Nothing) -> error "Federator is specified and RabbitMQ config is not, please specify both or none"
@@ -565,12 +581,8 @@ evalGalley e =
             }
         . interpretMeetingNotifier
         . interpretConversationSubsystem
-        . Meeting.interpretMeetingsSubsystem meetingLegacyTimeZone meetingValidityPeriod
+        . Meeting.interpretMeetingsSubsystem (meetingSystemConfig (e ^. options))
   where
-    meetingValidityPeriod =
-      realToFrac $ maybe (48 * 3600) (.duration) (e ^. options . settings . meetings >>= view validityPeriod)
-    meetingLegacyTimeZone =
-      fromMaybe defaultLegacyTimeZone (e ^. options . settings . meetings >>= view legacyTimeZone >>= parseTimeZone)
     lh = view (options . settings . featureFlags . to npProject) e
     legalHoldEnv =
       let makeReq fpr url rb = runApp e (LHInternal.makeVerifiedRequest fpr url rb)

From cffb095ec1a210eed82e5e2d1ee5ec976d214ce3 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Wed, 19 Aug 2026 18:51:03 +0200
Subject: [PATCH 095/113] WPB-28155: create meeting conversations with access
 [invite, code] (#5464)

---
 .../WPB-28155-meeting-conversation-access-migration        | 1 +
 integration/test/Test/Meetings.hs                          | 2 ++
 .../20260819120000-meetings-conversation-access.sql        | 6 ++++++
 .../src/Wire/MeetingsSubsystem/Interpreter.hs              | 3 ++-
 .../test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs    | 7 +++----
 5 files changed, 14 insertions(+), 5 deletions(-)
 create mode 100644 changelog.d/0-release-notes/WPB-28155-meeting-conversation-access-migration
 create mode 100644 libs/wire-subsystems/postgres-migrations/20260819120000-meetings-conversation-access.sql

diff --git a/changelog.d/0-release-notes/WPB-28155-meeting-conversation-access-migration b/changelog.d/0-release-notes/WPB-28155-meeting-conversation-access-migration
new file mode 100644
index 00000000000..d3dd31d463d
--- /dev/null
+++ b/changelog.d/0-release-notes/WPB-28155-meeting-conversation-access-migration
@@ -0,0 +1 @@
+Update meeting conversations from `{private, invite}` to `{invite, code}` so meetings can be joined by code.
diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs
index ae28999b79f..779b9aed3c1 100644
--- a/integration/test/Test/Meetings.hs
+++ b/integration/test/Test/Meetings.hs
@@ -44,6 +44,8 @@ testMeetingCreate = do
   -- The full conversation is returned alongside the legacy field
   assertConversationMatchesLegacy meeting
 
+  meeting %. "conversation" %. "access" `shouldMatchSet` ["invite", "code"]
+
   -- Verify fetching the meeting
   (meetingId, domain) <- getMeetingIdAndDomain meeting
   r2 <- getMeeting owner domain meetingId
diff --git a/libs/wire-subsystems/postgres-migrations/20260819120000-meetings-conversation-access.sql b/libs/wire-subsystems/postgres-migrations/20260819120000-meetings-conversation-access.sql
new file mode 100644
index 00000000000..781192475f0
--- /dev/null
+++ b/libs/wire-subsystems/postgres-migrations/20260819120000-meetings-conversation-access.sql
@@ -0,0 +1,6 @@
+-- Meeting conversations are created with access {invite, code} instead of
+-- {private, invite}, so that they can be joined by conversation code
+-- (WPB-28155). Backfill existing meeting conversations with the new set.
+-- Access ints (accessToInt32): private=1, invite=2, link=3, code=4.
+-- GroupConvType ints (fromEnum): group=0, channel=1, meeting=2.
+UPDATE conversation SET access = '{2,4}' WHERE group_conv_type = 2;
diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
index d53ec9d4b88..97648648326 100644
--- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
+++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
@@ -193,7 +193,8 @@ createMeetingImpl zUser connId newMeeting = do
             newConvName = Just newMeeting.title,
             -- InviteAccess is required so MLS commits can add participants via
             -- performConversationJoin (ensureAccess conv InviteAccess).
-            newConvAccess = Set.fromList [PrivateAccess, InviteAccess],
+            -- CodeAccess allows joining the meeting conversation by code.
+            newConvAccess = Set.fromList [InviteAccess, CodeAccess],
             newConvAccessRoles = Nothing,
             newConvTeam = ConvTeamInfo <$> conversationTeamId,
             newConvMessageTimer = Nothing,
diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
index 28eb0dbb2a2..4b38ab039e4 100644
--- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
+++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
@@ -43,7 +43,7 @@ import Test.Hspec
 import Test.Hspec.QuickCheck (prop)
 import Test.QuickCheck (NonNegative, counterexample, getNonNegative, ioProperty, (.&&.), (===), (==>))
 import Text.Email.Parser (unsafeEmailAddress)
-import Wire.API.Conversation (Access (InviteAccess, PrivateAccess), Conversation (metadata, qualifiedId), ConversationMetadata (cnvmAccess))
+import Wire.API.Conversation (Access (CodeAccess, InviteAccess), Conversation (metadata, qualifiedId), ConversationMetadata (cnvmAccess))
 import Wire.API.Error (ErrorS)
 import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound))
 import Wire.API.Event.Meeting qualified as MeetingEvent
@@ -183,7 +183,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
         meeting.conversation.qualifiedId `shouldBe` meeting.meeting.conversationId
         fetched `shouldBe` Just meeting.meeting
 
-  it "creates meeting conversation with invite access for MLS participant adds" $ do
+  it "creates meeting conversation with invite and code access" $ do
     let now = UTCTime (fromGregorian 2026 1 1) 0
         gen = mkStdGen 42
         uid = Id $ read "00000000-0000-0000-0000-000000000001"
@@ -205,8 +205,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
     case result of
       Left err -> fail $ "Error: " <> show err
       Right access -> do
-        PrivateAccess `elem` access `shouldBe` True
-        InviteAccess `elem` access `shouldBe` True
+        access `shouldBe` [InviteAccess, CodeAccess]
 
   it "fails to create a meeting if end_time is not after start_time" $ do
     let now = UTCTime (fromGregorian 2026 1 1) 0

From 4f5c093234e212bd0a40d53fd0c661aeeeeffa50 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Wed, 19 Aug 2026 23:34:46 +0200
Subject: [PATCH 096/113] WPB-28093: add dedicated Meeting errors description
 (#5455)

---
 .../WPB-28083-meeting-errors-exposed          |  1 +
 libs/wire-api/src/Wire/API/Error/Galley.hs    | 48 +++++++++++++++++++
 .../Wire/API/Routes/Public/Galley/Meetings.hs | 12 +++--
 .../src/Wire/MeetingsSubsystem/Interpreter.hs | 15 +++---
 .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 20 ++++----
 .../src/Wire/MeetingsCleanupWorker.hs         |  1 +
 services/galley/src/Galley/App.hs             | 13 ++---
 7 files changed, 76 insertions(+), 34 deletions(-)
 create mode 100644 changelog.d/1-api-changes/WPB-28083-meeting-errors-exposed

diff --git a/changelog.d/1-api-changes/WPB-28083-meeting-errors-exposed b/changelog.d/1-api-changes/WPB-28083-meeting-errors-exposed
new file mode 100644
index 00000000000..43819d29ae9
--- /dev/null
+++ b/changelog.d/1-api-changes/WPB-28083-meeting-errors-exposed
@@ -0,0 +1 @@
+Meeting endpoints (`POST /meetings`, `PUT/DELETE /meetings/:domain/:id`, meeting invitation endpoints): errors have dedicated descriptions.
diff --git a/libs/wire-api/src/Wire/API/Error/Galley.hs b/libs/wire-api/src/Wire/API/Error/Galley.hs
index 47e020fca0b..895bae66914 100644
--- a/libs/wire-api/src/Wire/API/Error/Galley.hs
+++ b/libs/wire-api/src/Wire/API/Error/Galley.hs
@@ -50,6 +50,8 @@ module Wire.API.Error.Galley
     GroupInfoDiagnostics (..),
     MLSOutOfSyncError (..),
     AdminlessConversation (..),
+    MeetingError (..),
+    InvalidTimesReason (..),
   )
 where
 
@@ -394,6 +396,52 @@ type instance MapError 'MeetingNotFound = 'StaticError 404 "meeting-not-found" "
 
 type instance MapError 'CodeStoreNotFound = 'StaticError 404 "code-store-not-found" "Code store not found"
 
+-- | Errors raised by the meetings subsystem, exposed at the API level via
+-- @CanThrow 'MeetingError'@. Every constructor keeps the @invalid-op@ label
+-- but carries an explicit message; 'InvalidTimes' additionally records which
+-- validation failed.
+data MeetingError
+  = InvalidTimes InvalidTimesReason
+  | EmptyUpdate
+  | MeetingsFeatureDisabled
+  deriving stock (Eq, Show)
+
+-- | The individual failure modes of meeting time validation.
+data InvalidTimesReason
+  = -- | The (effective) end time is not after the start time.
+    EndBeforeStart
+  | -- | The start time of a new meeting is too far in the past.
+    StartTimeTooFarInPast
+  | -- | An updated time is further in the past than the past-edit window allows.
+    TimesBeyondPastEditWindow
+  deriving stock (Eq, Show)
+
+meetingErrorToDyn :: MeetingError -> DynError
+meetingErrorToDyn = \case
+  InvalidTimes EndBeforeStart ->
+    DynError 403 "invalid-op" "Meeting end time must be after the start time"
+  InvalidTimes StartTimeTooFarInPast ->
+    DynError 403 "invalid-op" "Meeting start time is too far in the past"
+  InvalidTimes TimesBeyondPastEditWindow ->
+    DynError 403 "invalid-op" "Meeting times must not be set further into the past than the past-edit window allows"
+  EmptyUpdate -> DynError 403 "invalid-op" "Meeting update must set at least one field"
+  MeetingsFeatureDisabled -> DynError 403 "invalid-op" "The meetings feature is not enabled"
+
+type instance ErrorEffect MeetingError = Error MeetingError
+
+instance APIError MeetingError where
+  toResponse = toResponse . meetingErrorToDyn
+
+-- | The swagger documentation for 'MeetingError': one representative static
+-- error (the runtime messages are more detailed but share code and label).
+type MeetingErrorStatic = 'StaticError 403 "invalid-op" "Invalid meeting times, empty update, or meetings feature disabled"
+
+instance IsSwaggerError MeetingError where
+  addToOpenApi = addStaticErrorToSwagger @MeetingErrorStatic
+
+instance (Member (Error DynError) r) => ServerEffect (Error MeetingError) r where
+  interpretServerEffect = mapError meetingErrorToDyn
+
 --------------------------------------------------------------------------------
 -- Team Member errors
 
diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs
index 4fb6b186742..3440cccfa96 100644
--- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs
+++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs
@@ -38,7 +38,7 @@ type MeetingsAPI =
         :> ZConn
         :> "meetings"
         :> ReqBody '[JSON] NewMeetingV16
-        :> CanThrow 'InvalidOperation
+        :> CanThrow MeetingError
         :> CanThrow UnreachableBackends
         :> MultiVerb
              'POST
@@ -54,7 +54,7 @@ type MeetingsAPI =
                :> ZConn
                :> "meetings"
                :> ReqBody '[JSON] NewMeeting
-               :> CanThrow 'InvalidOperation
+               :> CanThrow MeetingError
                :> CanThrow UnreachableBackends
                :> MultiVerb
                     'POST
@@ -74,7 +74,7 @@ type MeetingsAPI =
                :> Capture "id" MeetingId
                :> CanThrow 'MeetingNotFound
                :> CanThrow 'AccessDenied
-               :> CanThrow 'InvalidOperation
+               :> CanThrow MeetingError
                :> ReqBody '[JSON] UpdateMeetingV16
                :> MultiVerb
                     'PUT
@@ -93,7 +93,7 @@ type MeetingsAPI =
                :> Capture "id" MeetingId
                :> CanThrow 'MeetingNotFound
                :> CanThrow 'AccessDenied
-               :> CanThrow 'InvalidOperation
+               :> CanThrow MeetingError
                :> ReqBody '[JSON] UpdateMeeting
                :> MultiVerb
                     'PUT
@@ -112,6 +112,7 @@ type MeetingsAPI =
                :> Capture "id" MeetingId
                :> CanThrow 'MeetingNotFound
                :> CanThrow 'AccessDenied
+               :> CanThrow MeetingError
                :> MultiVerb
                     'DELETE
                     '[JSON]
@@ -177,6 +178,7 @@ type MeetingsAPI =
                :> "invitations"
                :> CanThrow 'MeetingNotFound
                :> CanThrow 'AccessDenied
+               :> CanThrow MeetingError
                :> ReqBody '[JSON] MeetingEmailsInvitation
                :> MultiVerb
                     'POST
@@ -196,6 +198,7 @@ type MeetingsAPI =
                :> "delete"
                :> CanThrow 'MeetingNotFound
                :> CanThrow 'AccessDenied
+               :> CanThrow MeetingError
                :> ReqBody '[JSON] MeetingEmailsInvitation
                :> MultiVerb
                     'POST
@@ -214,6 +217,7 @@ type MeetingsAPI =
                :> "invitations"
                :> CanThrow 'MeetingNotFound
                :> CanThrow 'AccessDenied
+               :> CanThrow MeetingError
                :> ReqBody '[JSON] MeetingEmailsInvitation
                :> MultiVerb
                     'PUT
diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
index 97648648326..88e764b9f63 100644
--- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
+++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs
@@ -19,7 +19,6 @@ module Wire.MeetingsSubsystem.Interpreter
   ( MeetingSystemConfig (..),
     interpretMeetingsSubsystem,
     startTimeTolerance,
-    MeetingError (..),
   )
 where
 
@@ -42,6 +41,7 @@ import Polysemy.TinyLog qualified as TinyLog
 import System.Logger qualified as Log
 import Wire.API.Conversation hiding (Member)
 import Wire.API.Conversation.Role (roleNameWireAdmin)
+import Wire.API.Error.Galley (InvalidTimesReason (..), MeetingError (..))
 import Wire.API.Event.Meeting qualified as MeetingEvent
 import Wire.API.Meeting qualified as API
 import Wire.API.Routes.MultiTablePaging qualified as MultiTablePaging
@@ -59,9 +59,6 @@ import Wire.StoredConversation
 import Wire.TeamSubsystem (TeamSubsystem)
 import Wire.TeamSubsystem qualified as TeamSubsystem
 
-data MeetingError = InvalidTimes | EmptyUpdate | MeetingsFeatureDisabled
-  deriving stock (Eq, Show)
-
 -- | Tolerance applied when validating that a meeting's start time is not in
 -- the past. The check always uses the server's clock ('Now.get') as the
 -- reference; the client's clock is never trusted. The tolerance only absorbs
@@ -174,11 +171,11 @@ createMeetingImpl zUser connId newMeeting = do
   -- Validate that the meeting ends after it starts (end_time is the source of
   -- truth; positivity was previously checked on the derived duration).
   when (newMeeting.endTime <= newMeeting.startTime) $
-    throw InvalidTimes
+    throw (InvalidTimes EndBeforeStart)
   -- Validate that startTime is not in the past (within tolerance)
   now <- Now.get
   when (newMeeting.startTime < addUTCTime (negate startTimeTolerance) now) $
-    throw InvalidTimes
+    throw (InvalidTimes StartTimeTooFarInPast)
 
   -- Determine trial status: personal users (no team) create trial meetings.
   -- The deprecated meetingsPremium feature flag no longer affects this; team
@@ -265,14 +262,14 @@ updateMeetingImpl zUser connId meetingId update validityPeriod pastEditPeriod =
     -- bound may change independently.
     when (fromMaybe meeting.startTime update.startTime >= fromMaybe meeting.endTime update.endTime) $
       lift $
-        throw InvalidTimes
+        throw (InvalidTimes EndBeforeStart)
     -- New time values may be moved into the past so that past/ongoing
     -- meetings can be corrected to what actually happened, but no further
     -- than `pastEditPeriod` (WPB-28080). Only provided values are checked;
     -- unchanged stored times are not re-validated.
     let pastEditCutoff = addUTCTime (negate pastEditPeriod) now
-    for_ update.startTime $ \t -> when (t < pastEditCutoff) $ lift $ throw InvalidTimes
-    for_ update.endTime $ \t -> when (t < pastEditCutoff) $ lift $ throw InvalidTimes
+    for_ update.startTime $ \t -> when (t < pastEditCutoff) $ lift $ throw (InvalidTimes TimesBeyondPastEditWindow)
+    for_ update.endTime $ \t -> when (t < pastEditCutoff) $ lift $ throw (InvalidTimes TimesBeyondPastEditWindow)
 
     updatedMeeting <-
       MaybeT $
diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
index 4b38ab039e4..ecb729b5b89 100644
--- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
+++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs
@@ -45,7 +45,7 @@ import Test.QuickCheck (NonNegative, counterexample, getNonNegative, ioProperty,
 import Text.Email.Parser (unsafeEmailAddress)
 import Wire.API.Conversation (Access (CodeAccess, InviteAccess), Conversation (metadata, qualifiedId), ConversationMetadata (cnvmAccess))
 import Wire.API.Error (ErrorS)
-import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound))
+import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound), InvalidTimesReason (..), MeetingError (..))
 import Wire.API.Event.Meeting qualified as MeetingEvent
 import Wire.API.Meeting qualified as API
 import Wire.API.PostgresMarshall (PostgresUnmarshall (postgresUnmarshall))
@@ -223,7 +223,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             }
 
     result <- runTestStack now gen Map.empty def $ createMeeting zUser (ConnId "test-conn") newMeeting
-    result `shouldBe` Left InvalidTimes
+    result `shouldBe` Left (InvalidTimes EndBeforeStart)
 
   it "fails to create a meeting if start time is in the past" $ do
     let now = UTCTime (fromGregorian 2026 1 1) 0
@@ -241,7 +241,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             }
 
     result <- runTestStack now gen Map.empty def $ createMeeting zUser (ConnId "test-conn") newMeeting
-    result `shouldBe` Left InvalidTimes
+    result `shouldBe` Left (InvalidTimes StartTimeTooFarInPast)
 
   it "accepts a meeting whose start time is exactly at the tolerance boundary" $ do
     let now = UTCTime (fromGregorian 2026 1 1) 0
@@ -280,7 +280,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
             }
 
     result <- runTestStack now gen Map.empty def $ createMeeting zUser (ConnId "test-conn") newMeeting
-    result `shouldBe` Left InvalidTimes
+    result `shouldBe` Left (InvalidTimes StartTimeTooFarInPast)
 
   describe "getMeeting access control" $ do
     let now = UTCTime (fromGregorian 2026 1 1) 0
@@ -425,8 +425,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
                   recurrence = Nothing
                 }
         updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update
-
-      result `shouldBe` Left InvalidTimes
+      result `shouldBe` Left (InvalidTimes EndBeforeStart)
 
     it "allows editing an upcoming meeting's start time into the past within the past-edit window" $ do
       let newMeeting =
@@ -503,8 +502,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
                   recurrence = Nothing
                 }
         updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update
-
-      result `shouldBe` Left InvalidTimes
+      result `shouldBe` Left (InvalidTimes TimesBeyondPastEditWindow)
 
     it "throws InvalidTimes when updating both times beyond the past-edit window even when start < end" $ do
       let newMeeting =
@@ -527,8 +525,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
                   recurrence = Nothing
                 }
         updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update
-
-      result `shouldBe` Left InvalidTimes
+      result `shouldBe` Left (InvalidTimes TimesBeyondPastEditWindow)
 
     it "allows editing an already-started (ongoing) meeting, including its start time" $ do
       let ongoingMeeting =
@@ -586,7 +583,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do
                     recurrence = Nothing
                   }
           updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update
-      result `shouldBe` Left InvalidTimes
+      result `shouldBe` Left (InvalidTimes EndBeforeStart)
+
     it "returns Nothing for expired meeting" $ do
       let newMeeting =
             API.NewMeeting
diff --git a/services/background-worker/src/Wire/MeetingsCleanupWorker.hs b/services/background-worker/src/Wire/MeetingsCleanupWorker.hs
index 50dcefc1b48..bd3300260e8 100644
--- a/services/background-worker/src/Wire/MeetingsCleanupWorker.hs
+++ b/services/background-worker/src/Wire/MeetingsCleanupWorker.hs
@@ -29,6 +29,7 @@ import Imports
 import Polysemy.Error (runError)
 import Prometheus (incCounter)
 import System.Logger qualified as Log
+import Wire.API.Error.Galley (MeetingError)
 import Wire.API.Meeting (defaultLegacyTimeZone)
 import Wire.BackgroundWorker.Env (AppT, Env (..), MeetingsCleanupMetrics (..))
 import Wire.Effects
diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs
index e3d6855dd3f..4d755d8c612 100644
--- a/services/galley/src/Galley/App.hs
+++ b/services/galley/src/Galley/App.hs
@@ -87,7 +87,7 @@ import UnliftIO.Exception qualified as UnliftIO
 import Wire.API.Conversation.Config (ConversationSubsystemConfig (..))
 import Wire.API.Conversation.Protocol
 import Wire.API.Error
-import Wire.API.Error.Galley (GalleyError (..), NonFederatingBackends, OperationDenied, UnreachableBackends)
+import Wire.API.Error.Galley (GalleyError (..), MeetingError, NonFederatingBackends, OperationDenied, UnreachableBackends)
 import Wire.API.Federation.Client
 import Wire.API.Federation.Error
 import Wire.API.MLS.Keys (MLSKeysByPurpose, MLSPrivateKeys)
@@ -251,7 +251,7 @@ type GalleyEffects =
      Input (Either HttpsUrl (Map Domain HttpsUrl)),
      Now,
      BoundedQueue DeleteItem,
-     Error Meeting.MeetingError,
+     Error MeetingError,
      Error DynError,
      Error RateLimitExceeded,
      Error ConversationSubsystemError,
@@ -523,7 +523,7 @@ evalGalley e =
         . mapError toResponse -- Error ConversationSubsystemError,
         . mapError rateLimitExceededToHttpError
         . mapError toResponse -- DynError
-        . mapError meetingError
+        . mapError toResponse -- Error MeetingError
         . interpretBoundedQueue (e ^. deleteQueue)
         . nowToIO
         . runInputConst (e ^. convCodeURI)
@@ -596,10 +596,3 @@ interpretTeamFeatureSpecialContext e =
 
 mapTeamFeatureStoreError :: TeamFeatureStoreError -> InternalError
 mapTeamFeatureStoreError (TeamFeatureStoreErrorInternalError msg) = InternalErrorWithDescription msg
-
-meetingError :: Meeting.MeetingError -> Servant.Tagged 'InvalidOperation ()
-meetingError =
-  \case
-    Meeting.InvalidTimes -> Servant.Tagged @'InvalidOperation ()
-    Meeting.EmptyUpdate -> Servant.Tagged @'InvalidOperation ()
-    Meeting.MeetingsFeatureDisabled -> Servant.Tagged @'InvalidOperation ()

From 1c50cfc339af05893b642e5c6d92433e30c8b21b Mon Sep 17 00:00:00 2001
From: Matthias Fischmann 
Date: Thu, 20 Aug 2026 09:56:17 +0200
Subject: [PATCH 097/113] [WPB--] Update postgres schema dump. (#5461)

---
 .../WPB---update-postgres-schema-dump         |    1 +
 postgres-schema.sql                           | 1691 ++++++++++++++++-
 2 files changed, 1689 insertions(+), 3 deletions(-)
 create mode 100644 changelog.d/5-internal/WPB---update-postgres-schema-dump

diff --git a/changelog.d/5-internal/WPB---update-postgres-schema-dump b/changelog.d/5-internal/WPB---update-postgres-schema-dump
new file mode 100644
index 00000000000..218f9c7ed9e
--- /dev/null
+++ b/changelog.d/5-internal/WPB---update-postgres-schema-dump
@@ -0,0 +1 @@
+Update postgres schema dump.
diff --git a/postgres-schema.sql b/postgres-schema.sql
index c2beceeb205..b2d1587ab49 100644
--- a/postgres-schema.sql
+++ b/postgres-schema.sql
@@ -9,8 +9,8 @@
 
 \restrict 79bbfb4630959c48307653a5cd3d83f2582b3c2210f75f10d79e3ebf0015620
 
--- Dumped from database version 17.9
--- Dumped by pg_dump version 17.9
+-- Dumped from database version 17.10
+-- Dumped by pg_dump version 17.10
 
 SET statement_timeout = 0;
 SET lock_timeout = 0;
@@ -24,6 +24,15 @@ SET xmloption = content;
 SET client_min_messages = warning;
 SET row_security = off;
 
+--
+-- Name: arbiter; Type: SCHEMA; Schema: -; Owner: wire-server
+--
+
+CREATE SCHEMA arbiter;
+
+
+ALTER SCHEMA arbiter OWNER TO "wire-server";
+
 --
 -- Name: public; Type: SCHEMA; Schema: -; Owner: wire-server
 --
@@ -54,6 +63,608 @@ CREATE TYPE public.recurrence_frequency AS ENUM (
 
 ALTER TYPE public.recurrence_frequency OWNER TO "wire-server";
 
+--
+-- Name: maintain_conversations_groups_delete(); Type: FUNCTION; Schema: arbiter; Owner: wire-server
+--
+
+CREATE FUNCTION arbiter.maintain_conversations_groups_delete() RETURNS trigger
+    LANGUAGE plpgsql
+    AS $$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM old_table WHERE group_key IS NOT NULL LIMIT 1) THEN
+    RETURN NULL;
+  END IF;
+
+  -- Lock group rows in group_key order to avoid deadlock with concurrent triggers.
+  PERFORM 1 FROM "arbiter"."conversations_groups" g
+  WHERE g.group_key IN (SELECT group_key FROM old_table WHERE group_key IS NOT NULL)
+  ORDER BY g.group_key
+  FOR UPDATE;
+
+  UPDATE "arbiter"."conversations_groups" g
+  SET job_count = g.job_count - sub.removed_count,
+      min_priority = COALESCE(sub.new_min_priority, g.min_priority),
+      min_id = COALESCE(sub.new_min_id, g.min_id),
+      ready_count = GREATEST(0, g.ready_count - sub.removed_ready_count),
+      next_due = sub.new_next_due,
+      in_flight_until = CASE
+        WHEN sub.had_inflight THEN sub.surviving_ift
+        ELSE g.in_flight_until
+      END
+  FROM (
+    SELECT d.group_key, d.removed_count, d.removed_ready_count, d.had_inflight,
+      MIN(t.priority) AS new_min_priority,
+      MIN(t.id) AS new_min_id,
+      MIN(t.not_visible_until) FILTER (WHERE t.not_visible_until IS NOT NULL AND NOT t.suspended) AS new_next_due,
+      MAX(t.not_visible_until) FILTER (WHERE t.not_visible_until > NOW() AND NOT t.suspended AND (t.attempts > 0 OR t.throttled_until > NOW())) AS surviving_ift
+    FROM (
+      SELECT group_key, COUNT(*) AS removed_count,
+        COUNT(*) FILTER (WHERE not_visible_until IS NULL AND NOT suspended) AS removed_ready_count,
+        bool_or(not_visible_until > NOW() AND NOT suspended AND (attempts > 0 OR throttled_until > NOW())) AS had_inflight
+      FROM old_table
+      WHERE group_key IS NOT NULL
+      GROUP BY group_key
+    ) d
+    LEFT JOIN "arbiter"."conversations" t ON t.group_key = d.group_key
+    GROUP BY d.group_key, d.removed_count, d.removed_ready_count, d.had_inflight
+  ) sub
+  WHERE g.group_key = sub.group_key;
+
+  DELETE FROM "arbiter"."conversations_groups"
+  WHERE job_count <= 0
+    AND group_key IN (SELECT group_key FROM old_table WHERE group_key IS NOT NULL);
+
+  RETURN NULL;
+END;
+$$;
+
+
+ALTER FUNCTION arbiter.maintain_conversations_groups_delete() OWNER TO "wire-server";
+
+--
+-- Name: maintain_conversations_groups_insert(); Type: FUNCTION; Schema: arbiter; Owner: wire-server
+--
+
+CREATE FUNCTION arbiter.maintain_conversations_groups_insert() RETURNS trigger
+    LANGUAGE plpgsql
+    AS $$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM new_table WHERE group_key IS NOT NULL LIMIT 1) THEN
+    RETURN NULL;
+  END IF;
+
+  -- Lock group rows in group_key order to avoid deadlock with concurrent triggers.
+  PERFORM 1 FROM "arbiter"."conversations_groups" g
+  WHERE g.group_key IN (SELECT group_key FROM new_table WHERE group_key IS NOT NULL)
+  ORDER BY g.group_key
+  FOR UPDATE;
+
+  INSERT INTO "arbiter"."conversations_groups" (group_key, min_priority, min_id, job_count, ready_count, next_due)
+  SELECT group_key,
+    MIN(priority),
+    MIN(id),
+    COUNT(*),
+    COUNT(*) FILTER (WHERE not_visible_until IS NULL AND NOT suspended),
+    MIN(not_visible_until) FILTER (WHERE not_visible_until IS NOT NULL AND NOT suspended)
+  FROM new_table
+  WHERE group_key IS NOT NULL
+  GROUP BY group_key
+  ORDER BY group_key
+  ON CONFLICT (group_key) DO UPDATE SET
+    min_priority = LEAST("arbiter"."conversations_groups".min_priority, EXCLUDED.min_priority),
+    min_id = LEAST("arbiter"."conversations_groups".min_id, EXCLUDED.min_id),
+    job_count = "arbiter"."conversations_groups".job_count + EXCLUDED.job_count,
+    ready_count = "arbiter"."conversations_groups".ready_count + EXCLUDED.ready_count,
+    next_due = LEAST("arbiter"."conversations_groups".next_due, EXCLUDED.next_due),
+    in_flight_until = CASE WHEN "arbiter"."conversations_groups".in_flight_until <= NOW()
+      THEN NULL ELSE "arbiter"."conversations_groups".in_flight_until END;
+
+  RETURN NULL;
+END;
+$$;
+
+
+ALTER FUNCTION arbiter.maintain_conversations_groups_insert() OWNER TO "wire-server";
+
+--
+-- Name: maintain_conversations_groups_update(); Type: FUNCTION; Schema: arbiter; Owner: wire-server
+--
+
+CREATE FUNCTION arbiter.maintain_conversations_groups_update() RETURNS trigger
+    LANGUAGE plpgsql
+    AS $$
+BEGIN
+  IF NOT EXISTS (
+    SELECT 1 FROM new_table WHERE group_key IS NOT NULL LIMIT 1
+  ) AND NOT EXISTS (
+    SELECT 1 FROM old_table WHERE group_key IS NOT NULL LIMIT 1
+  ) THEN
+    RETURN NULL;
+  END IF;
+
+  -- Lock group rows (old and new) in group_key order to avoid deadlock with concurrent triggers.
+  PERFORM 1 FROM "arbiter"."conversations_groups" g
+  WHERE g.group_key IN (
+    SELECT group_key FROM new_table WHERE group_key IS NOT NULL
+    UNION
+    SELECT group_key FROM old_table WHERE group_key IS NOT NULL
+  )
+  ORDER BY g.group_key
+  FOR UPDATE;
+
+  -- Step 1: Full rescan - recompute in_flight_until when not_visible_until decreases or suspended changes
+  UPDATE "arbiter"."conversations_groups" g
+  SET in_flight_until = sub.new_ift
+  FROM (
+    SELECT t.group_key,
+      MAX(t.not_visible_until) FILTER (
+        WHERE t.not_visible_until > NOW() AND NOT t.suspended AND (t.attempts > 0 OR t.throttled_until > NOW())
+      ) AS new_ift
+    FROM "arbiter"."conversations" t
+    WHERE t.group_key IN (
+      SELECT n.group_key FROM new_table n
+      JOIN old_table o ON o.id = n.id
+      WHERE n.group_key IS NOT NULL
+        AND (o.not_visible_until IS DISTINCT FROM n.not_visible_until
+             OR o.suspended IS DISTINCT FROM n.suspended
+             OR o.attempts IS DISTINCT FROM n.attempts)
+        AND (
+          n.not_visible_until > NOW() AND NOT n.suspended AND n.attempts > 0
+          AND (o.not_visible_until IS NULL OR o.not_visible_until <= NOW()
+               OR n.not_visible_until > o.not_visible_until)
+        ) IS NOT TRUE
+    )
+    GROUP BY t.group_key
+  ) sub
+  WHERE g.group_key = sub.group_key
+    AND g.in_flight_until IS DISTINCT FROM sub.new_ift;
+
+  -- Step 2: group_key change (dedup replace) - remove from old group
+  UPDATE "arbiter"."conversations_groups" g
+  SET job_count = g.job_count - sub.cnt,
+      min_priority = COALESCE(sub.new_min_priority, g.min_priority),
+      min_id = COALESCE(sub.new_min_id, g.min_id),
+      ready_count = GREATEST(0, g.ready_count - sub.removed_ready_count),
+      next_due = sub.new_next_due,
+      in_flight_until = CASE
+        WHEN sub.had_inflight THEN sub.surviving_ift
+        ELSE g.in_flight_until
+      END
+  FROM (
+    SELECT d.group_key, d.cnt, d.removed_ready_count, d.had_inflight,
+      MIN(t.priority) AS new_min_priority, MIN(t.id) AS new_min_id,
+      MIN(t.not_visible_until) FILTER (WHERE t.not_visible_until IS NOT NULL AND NOT t.suspended) AS new_next_due,
+      MAX(t.not_visible_until) FILTER (WHERE t.not_visible_until > NOW() AND NOT t.suspended AND (t.attempts > 0 OR t.throttled_until > NOW())) AS surviving_ift
+    FROM (
+      SELECT o.group_key, COUNT(*) AS cnt,
+        COUNT(*) FILTER (WHERE o.not_visible_until IS NULL AND NOT o.suspended) AS removed_ready_count,
+        bool_or(o.not_visible_until > NOW() AND NOT o.suspended AND (o.attempts > 0 OR o.throttled_until > NOW())) AS had_inflight
+      FROM old_table o
+      JOIN new_table n ON o.id = n.id
+      WHERE o.group_key IS NOT NULL
+        AND o.group_key IS DISTINCT FROM n.group_key
+      GROUP BY o.group_key
+    ) d
+    LEFT JOIN "arbiter"."conversations" t ON t.group_key = d.group_key
+    GROUP BY d.group_key, d.cnt, d.removed_ready_count, d.had_inflight
+  ) sub
+  WHERE g.group_key = sub.group_key;
+
+  DELETE FROM "arbiter"."conversations_groups"
+  WHERE job_count <= 0
+    AND group_key IN (
+      SELECT o.group_key FROM old_table o
+      JOIN new_table n ON o.id = n.id
+      WHERE o.group_key IS NOT NULL
+        AND o.group_key IS DISTINCT FROM n.group_key
+    );
+
+  -- Step 3: group_key change - add to new group
+  INSERT INTO "arbiter"."conversations_groups" (group_key, min_priority, min_id, job_count, ready_count, next_due)
+  SELECT n.group_key, MIN(n.priority), MIN(n.id), COUNT(*),
+    COUNT(*) FILTER (WHERE n.not_visible_until IS NULL AND NOT n.suspended),
+    MIN(n.not_visible_until) FILTER (WHERE n.not_visible_until IS NOT NULL AND NOT n.suspended)
+  FROM new_table n
+  JOIN old_table o ON o.id = n.id
+  WHERE n.group_key IS NOT NULL
+    AND o.group_key IS DISTINCT FROM n.group_key
+  GROUP BY n.group_key
+  ORDER BY n.group_key
+  ON CONFLICT (group_key) DO UPDATE SET
+    min_priority = LEAST("arbiter"."conversations_groups".min_priority, EXCLUDED.min_priority),
+    min_id = LEAST("arbiter"."conversations_groups".min_id, EXCLUDED.min_id),
+    job_count = "arbiter"."conversations_groups".job_count + EXCLUDED.job_count,
+    ready_count = "arbiter"."conversations_groups".ready_count + EXCLUDED.ready_count,
+    next_due = LEAST("arbiter"."conversations_groups".next_due, EXCLUDED.next_due);
+
+  -- Step 4: same-group ordering/visibility change - recompute min and next_due.
+  UPDATE "arbiter"."conversations_groups" g
+  SET min_priority = sub.new_min_priority,
+      min_id = sub.new_min_id,
+      next_due = sub.new_next_due
+  FROM (
+    SELECT d.group_key,
+      MIN(t.priority) AS new_min_priority,
+      MIN(t.id) AS new_min_id,
+      MIN(t.not_visible_until) FILTER (WHERE t.not_visible_until IS NOT NULL AND NOT t.suspended) AS new_next_due
+    FROM (
+      SELECT DISTINCT n.group_key
+      FROM new_table n
+      JOIN old_table o ON o.id = n.id
+      WHERE n.group_key IS NOT NULL
+        AND n.group_key IS NOT DISTINCT FROM o.group_key
+        AND (n.priority IS DISTINCT FROM o.priority
+             OR o.not_visible_until IS DISTINCT FROM n.not_visible_until
+             OR o.suspended IS DISTINCT FROM n.suspended)
+    ) d
+    LEFT JOIN "arbiter"."conversations" t ON t.group_key = d.group_key
+    GROUP BY d.group_key
+  ) sub
+  WHERE g.group_key = sub.group_key
+    AND (g.min_priority IS DISTINCT FROM sub.new_min_priority
+         OR g.min_id IS DISTINCT FROM sub.new_min_id
+         OR g.next_due IS DISTINCT FROM sub.new_next_due);
+
+  -- Step 5: commutative in_flight_until extend and ready_count delta in one write.
+  UPDATE "arbiter"."conversations_groups" g
+  SET in_flight_until = GREATEST(g.in_flight_until, s.new_ift),
+      ready_count = GREATEST(0, g.ready_count + COALESCE(s.delta, 0))
+  FROM (
+    SELECT COALESCE(ift.group_key, rc.group_key) AS group_key, ift.new_ift, rc.delta
+    FROM (
+      SELECT n.group_key, MAX(n.not_visible_until) AS new_ift
+      FROM new_table n
+      JOIN old_table o ON o.id = n.id
+      WHERE n.group_key IS NOT NULL
+        AND n.not_visible_until > NOW()
+        AND NOT n.suspended
+        AND n.attempts > 0
+        AND (o.not_visible_until IS NULL OR o.not_visible_until <= NOW()
+             OR n.not_visible_until > o.not_visible_until)
+      GROUP BY n.group_key
+    ) ift
+    FULL OUTER JOIN (
+      SELECT group_key, delta FROM (
+        SELECT n.group_key,
+          SUM(
+            (CASE WHEN n.not_visible_until IS NULL AND NOT n.suspended THEN 1 ELSE 0 END)
+            - (CASE WHEN o.not_visible_until IS NULL AND NOT o.suspended THEN 1 ELSE 0 END)
+          )::int AS delta
+        FROM new_table n
+        JOIN old_table o ON o.id = n.id
+        WHERE n.group_key IS NOT NULL
+          AND n.group_key IS NOT DISTINCT FROM o.group_key
+        GROUP BY n.group_key
+      ) z
+      WHERE delta <> 0
+    ) rc ON ift.group_key = rc.group_key
+  ) s
+  WHERE g.group_key = s.group_key;
+
+  RETURN NULL;
+END;
+$$;
+
+
+ALTER FUNCTION arbiter.maintain_conversations_groups_update() OWNER TO "wire-server";
+
+--
+-- Name: maintain_meetings_groups_delete(); Type: FUNCTION; Schema: arbiter; Owner: wire-server
+--
+
+CREATE FUNCTION arbiter.maintain_meetings_groups_delete() RETURNS trigger
+    LANGUAGE plpgsql
+    AS $$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM old_table WHERE group_key IS NOT NULL LIMIT 1) THEN
+    RETURN NULL;
+  END IF;
+
+  -- Lock group rows in group_key order to avoid deadlock with concurrent triggers.
+  PERFORM 1 FROM "arbiter"."meetings_groups" g
+  WHERE g.group_key IN (SELECT group_key FROM old_table WHERE group_key IS NOT NULL)
+  ORDER BY g.group_key
+  FOR UPDATE;
+
+  UPDATE "arbiter"."meetings_groups" g
+  SET job_count = g.job_count - sub.removed_count,
+      min_priority = COALESCE(sub.new_min_priority, g.min_priority),
+      min_id = COALESCE(sub.new_min_id, g.min_id),
+      ready_count = GREATEST(0, g.ready_count - sub.removed_ready_count),
+      next_due = sub.new_next_due,
+      in_flight_until = CASE
+        WHEN sub.had_inflight THEN sub.surviving_ift
+        ELSE g.in_flight_until
+      END
+  FROM (
+    SELECT d.group_key, d.removed_count, d.removed_ready_count, d.had_inflight,
+      MIN(t.priority) AS new_min_priority,
+      MIN(t.id) AS new_min_id,
+      MIN(t.not_visible_until) FILTER (WHERE t.not_visible_until IS NOT NULL AND NOT t.suspended) AS new_next_due,
+      MAX(t.not_visible_until) FILTER (WHERE t.not_visible_until > NOW() AND NOT t.suspended AND (t.attempts > 0 OR t.throttled_until > NOW())) AS surviving_ift
+    FROM (
+      SELECT group_key, COUNT(*) AS removed_count,
+        COUNT(*) FILTER (WHERE not_visible_until IS NULL AND NOT suspended) AS removed_ready_count,
+        bool_or(not_visible_until > NOW() AND NOT suspended AND (attempts > 0 OR throttled_until > NOW())) AS had_inflight
+      FROM old_table
+      WHERE group_key IS NOT NULL
+      GROUP BY group_key
+    ) d
+    LEFT JOIN "arbiter"."meetings" t ON t.group_key = d.group_key
+    GROUP BY d.group_key, d.removed_count, d.removed_ready_count, d.had_inflight
+  ) sub
+  WHERE g.group_key = sub.group_key;
+
+  DELETE FROM "arbiter"."meetings_groups"
+  WHERE job_count <= 0
+    AND group_key IN (SELECT group_key FROM old_table WHERE group_key IS NOT NULL);
+
+  RETURN NULL;
+END;
+$$;
+
+
+ALTER FUNCTION arbiter.maintain_meetings_groups_delete() OWNER TO "wire-server";
+
+--
+-- Name: maintain_meetings_groups_insert(); Type: FUNCTION; Schema: arbiter; Owner: wire-server
+--
+
+CREATE FUNCTION arbiter.maintain_meetings_groups_insert() RETURNS trigger
+    LANGUAGE plpgsql
+    AS $$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM new_table WHERE group_key IS NOT NULL LIMIT 1) THEN
+    RETURN NULL;
+  END IF;
+
+  -- Lock group rows in group_key order to avoid deadlock with concurrent triggers.
+  PERFORM 1 FROM "arbiter"."meetings_groups" g
+  WHERE g.group_key IN (SELECT group_key FROM new_table WHERE group_key IS NOT NULL)
+  ORDER BY g.group_key
+  FOR UPDATE;
+
+  INSERT INTO "arbiter"."meetings_groups" (group_key, min_priority, min_id, job_count, ready_count, next_due)
+  SELECT group_key,
+    MIN(priority),
+    MIN(id),
+    COUNT(*),
+    COUNT(*) FILTER (WHERE not_visible_until IS NULL AND NOT suspended),
+    MIN(not_visible_until) FILTER (WHERE not_visible_until IS NOT NULL AND NOT suspended)
+  FROM new_table
+  WHERE group_key IS NOT NULL
+  GROUP BY group_key
+  ORDER BY group_key
+  ON CONFLICT (group_key) DO UPDATE SET
+    min_priority = LEAST("arbiter"."meetings_groups".min_priority, EXCLUDED.min_priority),
+    min_id = LEAST("arbiter"."meetings_groups".min_id, EXCLUDED.min_id),
+    job_count = "arbiter"."meetings_groups".job_count + EXCLUDED.job_count,
+    ready_count = "arbiter"."meetings_groups".ready_count + EXCLUDED.ready_count,
+    next_due = LEAST("arbiter"."meetings_groups".next_due, EXCLUDED.next_due),
+    in_flight_until = CASE WHEN "arbiter"."meetings_groups".in_flight_until <= NOW()
+      THEN NULL ELSE "arbiter"."meetings_groups".in_flight_until END;
+
+  RETURN NULL;
+END;
+$$;
+
+
+ALTER FUNCTION arbiter.maintain_meetings_groups_insert() OWNER TO "wire-server";
+
+--
+-- Name: maintain_meetings_groups_update(); Type: FUNCTION; Schema: arbiter; Owner: wire-server
+--
+
+CREATE FUNCTION arbiter.maintain_meetings_groups_update() RETURNS trigger
+    LANGUAGE plpgsql
+    AS $$
+BEGIN
+  IF NOT EXISTS (
+    SELECT 1 FROM new_table WHERE group_key IS NOT NULL LIMIT 1
+  ) AND NOT EXISTS (
+    SELECT 1 FROM old_table WHERE group_key IS NOT NULL LIMIT 1
+  ) THEN
+    RETURN NULL;
+  END IF;
+
+  -- Lock group rows (old and new) in group_key order to avoid deadlock with concurrent triggers.
+  PERFORM 1 FROM "arbiter"."meetings_groups" g
+  WHERE g.group_key IN (
+    SELECT group_key FROM new_table WHERE group_key IS NOT NULL
+    UNION
+    SELECT group_key FROM old_table WHERE group_key IS NOT NULL
+  )
+  ORDER BY g.group_key
+  FOR UPDATE;
+
+  -- Step 1: Full rescan - recompute in_flight_until when not_visible_until decreases or suspended changes
+  UPDATE "arbiter"."meetings_groups" g
+  SET in_flight_until = sub.new_ift
+  FROM (
+    SELECT t.group_key,
+      MAX(t.not_visible_until) FILTER (
+        WHERE t.not_visible_until > NOW() AND NOT t.suspended AND (t.attempts > 0 OR t.throttled_until > NOW())
+      ) AS new_ift
+    FROM "arbiter"."meetings" t
+    WHERE t.group_key IN (
+      SELECT n.group_key FROM new_table n
+      JOIN old_table o ON o.id = n.id
+      WHERE n.group_key IS NOT NULL
+        AND (o.not_visible_until IS DISTINCT FROM n.not_visible_until
+             OR o.suspended IS DISTINCT FROM n.suspended
+             OR o.attempts IS DISTINCT FROM n.attempts)
+        AND (
+          n.not_visible_until > NOW() AND NOT n.suspended AND n.attempts > 0
+          AND (o.not_visible_until IS NULL OR o.not_visible_until <= NOW()
+               OR n.not_visible_until > o.not_visible_until)
+        ) IS NOT TRUE
+    )
+    GROUP BY t.group_key
+  ) sub
+  WHERE g.group_key = sub.group_key
+    AND g.in_flight_until IS DISTINCT FROM sub.new_ift;
+
+  -- Step 2: group_key change (dedup replace) - remove from old group
+  UPDATE "arbiter"."meetings_groups" g
+  SET job_count = g.job_count - sub.cnt,
+      min_priority = COALESCE(sub.new_min_priority, g.min_priority),
+      min_id = COALESCE(sub.new_min_id, g.min_id),
+      ready_count = GREATEST(0, g.ready_count - sub.removed_ready_count),
+      next_due = sub.new_next_due,
+      in_flight_until = CASE
+        WHEN sub.had_inflight THEN sub.surviving_ift
+        ELSE g.in_flight_until
+      END
+  FROM (
+    SELECT d.group_key, d.cnt, d.removed_ready_count, d.had_inflight,
+      MIN(t.priority) AS new_min_priority, MIN(t.id) AS new_min_id,
+      MIN(t.not_visible_until) FILTER (WHERE t.not_visible_until IS NOT NULL AND NOT t.suspended) AS new_next_due,
+      MAX(t.not_visible_until) FILTER (WHERE t.not_visible_until > NOW() AND NOT t.suspended AND (t.attempts > 0 OR t.throttled_until > NOW())) AS surviving_ift
+    FROM (
+      SELECT o.group_key, COUNT(*) AS cnt,
+        COUNT(*) FILTER (WHERE o.not_visible_until IS NULL AND NOT o.suspended) AS removed_ready_count,
+        bool_or(o.not_visible_until > NOW() AND NOT o.suspended AND (o.attempts > 0 OR o.throttled_until > NOW())) AS had_inflight
+      FROM old_table o
+      JOIN new_table n ON o.id = n.id
+      WHERE o.group_key IS NOT NULL
+        AND o.group_key IS DISTINCT FROM n.group_key
+      GROUP BY o.group_key
+    ) d
+    LEFT JOIN "arbiter"."meetings" t ON t.group_key = d.group_key
+    GROUP BY d.group_key, d.cnt, d.removed_ready_count, d.had_inflight
+  ) sub
+  WHERE g.group_key = sub.group_key;
+
+  DELETE FROM "arbiter"."meetings_groups"
+  WHERE job_count <= 0
+    AND group_key IN (
+      SELECT o.group_key FROM old_table o
+      JOIN new_table n ON o.id = n.id
+      WHERE o.group_key IS NOT NULL
+        AND o.group_key IS DISTINCT FROM n.group_key
+    );
+
+  -- Step 3: group_key change - add to new group
+  INSERT INTO "arbiter"."meetings_groups" (group_key, min_priority, min_id, job_count, ready_count, next_due)
+  SELECT n.group_key, MIN(n.priority), MIN(n.id), COUNT(*),
+    COUNT(*) FILTER (WHERE n.not_visible_until IS NULL AND NOT n.suspended),
+    MIN(n.not_visible_until) FILTER (WHERE n.not_visible_until IS NOT NULL AND NOT n.suspended)
+  FROM new_table n
+  JOIN old_table o ON o.id = n.id
+  WHERE n.group_key IS NOT NULL
+    AND o.group_key IS DISTINCT FROM n.group_key
+  GROUP BY n.group_key
+  ORDER BY n.group_key
+  ON CONFLICT (group_key) DO UPDATE SET
+    min_priority = LEAST("arbiter"."meetings_groups".min_priority, EXCLUDED.min_priority),
+    min_id = LEAST("arbiter"."meetings_groups".min_id, EXCLUDED.min_id),
+    job_count = "arbiter"."meetings_groups".job_count + EXCLUDED.job_count,
+    ready_count = "arbiter"."meetings_groups".ready_count + EXCLUDED.ready_count,
+    next_due = LEAST("arbiter"."meetings_groups".next_due, EXCLUDED.next_due);
+
+  -- Step 4: same-group ordering/visibility change - recompute min and next_due.
+  UPDATE "arbiter"."meetings_groups" g
+  SET min_priority = sub.new_min_priority,
+      min_id = sub.new_min_id,
+      next_due = sub.new_next_due
+  FROM (
+    SELECT d.group_key,
+      MIN(t.priority) AS new_min_priority,
+      MIN(t.id) AS new_min_id,
+      MIN(t.not_visible_until) FILTER (WHERE t.not_visible_until IS NOT NULL AND NOT t.suspended) AS new_next_due
+    FROM (
+      SELECT DISTINCT n.group_key
+      FROM new_table n
+      JOIN old_table o ON o.id = n.id
+      WHERE n.group_key IS NOT NULL
+        AND n.group_key IS NOT DISTINCT FROM o.group_key
+        AND (n.priority IS DISTINCT FROM o.priority
+             OR o.not_visible_until IS DISTINCT FROM n.not_visible_until
+             OR o.suspended IS DISTINCT FROM n.suspended)
+    ) d
+    LEFT JOIN "arbiter"."meetings" t ON t.group_key = d.group_key
+    GROUP BY d.group_key
+  ) sub
+  WHERE g.group_key = sub.group_key
+    AND (g.min_priority IS DISTINCT FROM sub.new_min_priority
+         OR g.min_id IS DISTINCT FROM sub.new_min_id
+         OR g.next_due IS DISTINCT FROM sub.new_next_due);
+
+  -- Step 5: commutative in_flight_until extend and ready_count delta in one write.
+  UPDATE "arbiter"."meetings_groups" g
+  SET in_flight_until = GREATEST(g.in_flight_until, s.new_ift),
+      ready_count = GREATEST(0, g.ready_count + COALESCE(s.delta, 0))
+  FROM (
+    SELECT COALESCE(ift.group_key, rc.group_key) AS group_key, ift.new_ift, rc.delta
+    FROM (
+      SELECT n.group_key, MAX(n.not_visible_until) AS new_ift
+      FROM new_table n
+      JOIN old_table o ON o.id = n.id
+      WHERE n.group_key IS NOT NULL
+        AND n.not_visible_until > NOW()
+        AND NOT n.suspended
+        AND n.attempts > 0
+        AND (o.not_visible_until IS NULL OR o.not_visible_until <= NOW()
+             OR n.not_visible_until > o.not_visible_until)
+      GROUP BY n.group_key
+    ) ift
+    FULL OUTER JOIN (
+      SELECT group_key, delta FROM (
+        SELECT n.group_key,
+          SUM(
+            (CASE WHEN n.not_visible_until IS NULL AND NOT n.suspended THEN 1 ELSE 0 END)
+            - (CASE WHEN o.not_visible_until IS NULL AND NOT o.suspended THEN 1 ELSE 0 END)
+          )::int AS delta
+        FROM new_table n
+        JOIN old_table o ON o.id = n.id
+        WHERE n.group_key IS NOT NULL
+          AND n.group_key IS NOT DISTINCT FROM o.group_key
+        GROUP BY n.group_key
+      ) z
+      WHERE delta <> 0
+    ) rc ON ift.group_key = rc.group_key
+  ) s
+  WHERE g.group_key = s.group_key;
+
+  RETURN NULL;
+END;
+$$;
+
+
+ALTER FUNCTION arbiter.maintain_meetings_groups_update() OWNER TO "wire-server";
+
+--
+-- Name: notify_conversations_created(); Type: FUNCTION; Schema: arbiter; Owner: wire-server
+--
+
+CREATE FUNCTION arbiter.notify_conversations_created() RETURNS trigger
+    LANGUAGE plpgsql
+    AS $$
+BEGIN
+  PERFORM pg_notify('conversations_created', '');
+  RETURN NEW;
+END;
+$$;
+
+
+ALTER FUNCTION arbiter.notify_conversations_created() OWNER TO "wire-server";
+
+--
+-- Name: notify_meetings_created(); Type: FUNCTION; Schema: arbiter; Owner: wire-server
+--
+
+CREATE FUNCTION arbiter.notify_meetings_created() RETURNS trigger
+    LANGUAGE plpgsql
+    AS $$
+BEGIN
+  PERFORM pg_notify('meetings_created', '');
+  RETURN NEW;
+END;
+$$;
+
+
+ALTER FUNCTION arbiter.notify_meetings_created() OWNER TO "wire-server";
+
 --
 -- Name: update_updated_at(); Type: FUNCTION; Schema: public; Owner: wire-server
 --
@@ -74,6 +685,563 @@ SET default_tablespace = '';
 
 SET default_table_access_method = heap;
 
+--
+-- Name: arbiter_concurrency; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE UNLOGGED TABLE arbiter.arbiter_concurrency (
+    concurrency_key text NOT NULL,
+    concurrency_prefix text NOT NULL,
+    in_flight integer DEFAULT 0 NOT NULL
+)
+WITH (fillfactor='80');
+
+
+ALTER TABLE arbiter.arbiter_concurrency OWNER TO "wire-server";
+
+--
+-- Name: arbiter_concurrency_policies; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.arbiter_concurrency_policies (
+    prefix_id text NOT NULL,
+    default_limit integer NOT NULL,
+    override_limit integer,
+    CONSTRAINT arbiter_concurrency_policies_default_limit_check CHECK ((default_limit > 0)),
+    CONSTRAINT arbiter_concurrency_policies_override_limit_check CHECK ((override_limit >= 0))
+);
+
+
+ALTER TABLE arbiter.arbiter_concurrency_policies OWNER TO "wire-server";
+
+--
+-- Name: arbiter_gates; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.arbiter_gates (
+    task_name text NOT NULL,
+    last_run_at timestamp with time zone DEFAULT '1970-01-01 00:00:00+00'::timestamp with time zone NOT NULL
+);
+
+
+ALTER TABLE arbiter.arbiter_gates OWNER TO "wire-server";
+
+--
+-- Name: arbiter_queues; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.arbiter_queues (
+    queue_name text NOT NULL,
+    paused boolean DEFAULT false NOT NULL,
+    paused_at timestamp with time zone,
+    metadata jsonb,
+    created_at timestamp with time zone DEFAULT now() NOT NULL,
+    updated_at timestamp with time zone DEFAULT now() NOT NULL
+);
+
+
+ALTER TABLE arbiter.arbiter_queues OWNER TO "wire-server";
+
+--
+-- Name: arbiter_rate_limit_policies; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.arbiter_rate_limit_policies (
+    prefix_id text NOT NULL,
+    default_max_tokens double precision NOT NULL,
+    default_refill_amount double precision NOT NULL,
+    default_interval double precision NOT NULL,
+    override_max_tokens double precision,
+    override_refill_amount double precision,
+    override_interval double precision,
+    CONSTRAINT arbiter_rate_limit_policies_default_interval_check CHECK ((default_interval > (0)::double precision)),
+    CONSTRAINT arbiter_rate_limit_policies_default_max_tokens_check CHECK ((default_max_tokens >= (0)::double precision)),
+    CONSTRAINT arbiter_rate_limit_policies_default_refill_amount_check CHECK ((default_refill_amount >= (0)::double precision)),
+    CONSTRAINT arbiter_rate_limit_policies_override_interval_check CHECK ((override_interval > (0)::double precision)),
+    CONSTRAINT arbiter_rate_limit_policies_override_max_tokens_check CHECK ((override_max_tokens >= (0)::double precision)),
+    CONSTRAINT arbiter_rate_limit_policies_override_refill_amount_check CHECK ((override_refill_amount >= (0)::double precision))
+);
+
+
+ALTER TABLE arbiter.arbiter_rate_limit_policies OWNER TO "wire-server";
+
+--
+-- Name: arbiter_rate_limits; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE UNLOGGED TABLE arbiter.arbiter_rate_limits (
+    rate_limit_key text NOT NULL,
+    policy_prefix text NOT NULL,
+    tokens double precision NOT NULL,
+    last_refill timestamp with time zone NOT NULL
+)
+WITH (fillfactor='80');
+
+
+ALTER TABLE arbiter.arbiter_rate_limits OWNER TO "wire-server";
+
+--
+-- Name: arbiter_workers; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.arbiter_workers (
+    worker_id uuid NOT NULL,
+    queue_name text NOT NULL,
+    host_name text,
+    worker_count integer,
+    started_at timestamp with time zone DEFAULT now() NOT NULL,
+    last_heartbeat timestamp with time zone DEFAULT now() NOT NULL,
+    shutting_down boolean DEFAULT false NOT NULL,
+    paused boolean DEFAULT false NOT NULL,
+    stale_threshold_secs double precision DEFAULT 300 NOT NULL,
+    metadata jsonb
+);
+
+
+ALTER TABLE arbiter.arbiter_workers OWNER TO "wire-server";
+
+--
+-- Name: conversations; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.conversations (
+    id bigint NOT NULL,
+    payload jsonb NOT NULL,
+    group_key text,
+    inserted_at timestamp with time zone DEFAULT now() NOT NULL,
+    updated_at timestamp with time zone,
+    last_attempted_at timestamp with time zone,
+    not_visible_until timestamp with time zone,
+    attempts integer DEFAULT 0 NOT NULL,
+    last_error text,
+    priority integer DEFAULT 0 NOT NULL,
+    dedup_key text,
+    dedup_strategy text,
+    max_attempts integer DEFAULT 10,
+    parent_id bigint,
+    parent_state jsonb,
+    suspended boolean DEFAULT false NOT NULL,
+    claimed_by uuid,
+    rate_limit_key text,
+    rate_limit_prefix text,
+    throttled_until timestamp with time zone,
+    concurrency_key text,
+    concurrency_prefix text,
+    rate_limit_cost double precision DEFAULT 1 NOT NULL,
+    cancel_requested_at timestamp with time zone,
+    archive_for integer
+)
+WITH (fillfactor='100');
+
+
+ALTER TABLE arbiter.conversations OWNER TO "wire-server";
+
+--
+-- Name: conversations_archive; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.conversations_archive (
+    id bigint NOT NULL,
+    completed_at timestamp with time zone DEFAULT now() NOT NULL,
+    archive_expires_at timestamp with time zone NOT NULL,
+    job_id bigint NOT NULL,
+    claimed_by uuid,
+    archive_for integer,
+    rate_limit_key text,
+    rate_limit_prefix text,
+    rate_limit_cost double precision,
+    concurrency_key text,
+    concurrency_prefix text,
+    result jsonb,
+    payload jsonb NOT NULL,
+    group_key text,
+    inserted_at timestamp with time zone DEFAULT now() NOT NULL,
+    updated_at timestamp with time zone,
+    last_attempted_at timestamp with time zone,
+    not_visible_until timestamp with time zone,
+    attempts integer DEFAULT 0 NOT NULL,
+    last_error text,
+    priority integer DEFAULT 0 NOT NULL,
+    dedup_key text,
+    dedup_strategy text,
+    max_attempts integer,
+    parent_id bigint,
+    parent_state jsonb,
+    suspended boolean DEFAULT false NOT NULL
+);
+
+
+ALTER TABLE arbiter.conversations_archive OWNER TO "wire-server";
+
+--
+-- Name: conversations_archive_id_seq; Type: SEQUENCE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE SEQUENCE arbiter.conversations_archive_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+ALTER SEQUENCE arbiter.conversations_archive_id_seq OWNER TO "wire-server";
+
+--
+-- Name: conversations_archive_id_seq; Type: SEQUENCE OWNED BY; Schema: arbiter; Owner: wire-server
+--
+
+ALTER SEQUENCE arbiter.conversations_archive_id_seq OWNED BY arbiter.conversations_archive.id;
+
+
+--
+-- Name: conversations_dlq; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.conversations_dlq (
+    id bigint NOT NULL,
+    failed_at timestamp with time zone DEFAULT now() NOT NULL,
+    job_id bigint NOT NULL,
+    payload jsonb NOT NULL,
+    group_key text,
+    inserted_at timestamp with time zone DEFAULT now() NOT NULL,
+    updated_at timestamp with time zone,
+    last_attempted_at timestamp with time zone,
+    not_visible_until timestamp with time zone,
+    attempts integer DEFAULT 0 NOT NULL,
+    last_error text,
+    priority integer DEFAULT 0 NOT NULL,
+    dedup_key text,
+    dedup_strategy text,
+    max_attempts integer,
+    parent_id bigint,
+    parent_state jsonb,
+    suspended boolean DEFAULT false NOT NULL,
+    claimed_by uuid,
+    rate_limit_key text,
+    rate_limit_prefix text,
+    concurrency_key text,
+    concurrency_prefix text,
+    rate_limit_cost double precision DEFAULT 1 NOT NULL,
+    archive_for integer
+);
+
+
+ALTER TABLE arbiter.conversations_dlq OWNER TO "wire-server";
+
+--
+-- Name: conversations_dlq_id_seq; Type: SEQUENCE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE SEQUENCE arbiter.conversations_dlq_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+ALTER SEQUENCE arbiter.conversations_dlq_id_seq OWNER TO "wire-server";
+
+--
+-- Name: conversations_dlq_id_seq; Type: SEQUENCE OWNED BY; Schema: arbiter; Owner: wire-server
+--
+
+ALTER SEQUENCE arbiter.conversations_dlq_id_seq OWNED BY arbiter.conversations_dlq.id;
+
+
+--
+-- Name: conversations_groups; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.conversations_groups (
+    group_key text NOT NULL,
+    min_priority integer DEFAULT 0 NOT NULL,
+    min_id bigint DEFAULT 0 NOT NULL,
+    job_count integer DEFAULT 0 NOT NULL,
+    in_flight_until timestamp with time zone,
+    ready_count integer DEFAULT 0 NOT NULL,
+    next_due timestamp with time zone
+);
+
+
+ALTER TABLE arbiter.conversations_groups OWNER TO "wire-server";
+
+--
+-- Name: conversations_id_seq; Type: SEQUENCE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE SEQUENCE arbiter.conversations_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+ALTER SEQUENCE arbiter.conversations_id_seq OWNER TO "wire-server";
+
+--
+-- Name: conversations_id_seq; Type: SEQUENCE OWNED BY; Schema: arbiter; Owner: wire-server
+--
+
+ALTER SEQUENCE arbiter.conversations_id_seq OWNED BY arbiter.conversations.id;
+
+
+--
+-- Name: conversations_results; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.conversations_results (
+    parent_id bigint NOT NULL,
+    child_id bigint NOT NULL,
+    result jsonb NOT NULL
+);
+
+
+ALTER TABLE arbiter.conversations_results OWNER TO "wire-server";
+
+--
+-- Name: cron_schedules; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.cron_schedules (
+    name text NOT NULL,
+    default_expression text NOT NULL,
+    default_overlap text NOT NULL,
+    override_expression text,
+    override_overlap text,
+    enabled boolean DEFAULT true NOT NULL,
+    last_fired_at timestamp with time zone,
+    last_checked_at timestamp with time zone,
+    created_at timestamp with time zone DEFAULT now() NOT NULL,
+    updated_at timestamp with time zone DEFAULT now() NOT NULL,
+    default_timezone text,
+    override_timezone text,
+    queue_name text DEFAULT 'pre-migration'::text NOT NULL,
+    run_requested_at timestamp with time zone,
+    last_manual_run_at timestamp with time zone,
+    CONSTRAINT cron_schedules_default_overlap_check CHECK ((default_overlap = ANY (ARRAY['SkipOverlap'::text, 'AllowOverlap'::text]))),
+    CONSTRAINT cron_schedules_override_overlap_check CHECK (((override_overlap IS NULL) OR (override_overlap = ANY (ARRAY['SkipOverlap'::text, 'AllowOverlap'::text]))))
+);
+
+
+ALTER TABLE arbiter.cron_schedules OWNER TO "wire-server";
+
+--
+-- Name: meetings; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.meetings (
+    id bigint NOT NULL,
+    payload jsonb NOT NULL,
+    group_key text,
+    inserted_at timestamp with time zone DEFAULT now() NOT NULL,
+    updated_at timestamp with time zone,
+    last_attempted_at timestamp with time zone,
+    not_visible_until timestamp with time zone,
+    attempts integer DEFAULT 0 NOT NULL,
+    last_error text,
+    priority integer DEFAULT 0 NOT NULL,
+    dedup_key text,
+    dedup_strategy text,
+    max_attempts integer DEFAULT 10,
+    parent_id bigint,
+    parent_state jsonb,
+    suspended boolean DEFAULT false NOT NULL,
+    claimed_by uuid,
+    rate_limit_key text,
+    rate_limit_prefix text,
+    throttled_until timestamp with time zone,
+    concurrency_key text,
+    concurrency_prefix text,
+    rate_limit_cost double precision DEFAULT 1 NOT NULL,
+    cancel_requested_at timestamp with time zone,
+    archive_for integer
+)
+WITH (fillfactor='100');
+
+
+ALTER TABLE arbiter.meetings OWNER TO "wire-server";
+
+--
+-- Name: meetings_archive; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.meetings_archive (
+    id bigint NOT NULL,
+    completed_at timestamp with time zone DEFAULT now() NOT NULL,
+    archive_expires_at timestamp with time zone NOT NULL,
+    job_id bigint NOT NULL,
+    claimed_by uuid,
+    archive_for integer,
+    rate_limit_key text,
+    rate_limit_prefix text,
+    rate_limit_cost double precision,
+    concurrency_key text,
+    concurrency_prefix text,
+    result jsonb,
+    payload jsonb NOT NULL,
+    group_key text,
+    inserted_at timestamp with time zone DEFAULT now() NOT NULL,
+    updated_at timestamp with time zone,
+    last_attempted_at timestamp with time zone,
+    not_visible_until timestamp with time zone,
+    attempts integer DEFAULT 0 NOT NULL,
+    last_error text,
+    priority integer DEFAULT 0 NOT NULL,
+    dedup_key text,
+    dedup_strategy text,
+    max_attempts integer,
+    parent_id bigint,
+    parent_state jsonb,
+    suspended boolean DEFAULT false NOT NULL
+);
+
+
+ALTER TABLE arbiter.meetings_archive OWNER TO "wire-server";
+
+--
+-- Name: meetings_archive_id_seq; Type: SEQUENCE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE SEQUENCE arbiter.meetings_archive_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+ALTER SEQUENCE arbiter.meetings_archive_id_seq OWNER TO "wire-server";
+
+--
+-- Name: meetings_archive_id_seq; Type: SEQUENCE OWNED BY; Schema: arbiter; Owner: wire-server
+--
+
+ALTER SEQUENCE arbiter.meetings_archive_id_seq OWNED BY arbiter.meetings_archive.id;
+
+
+--
+-- Name: meetings_dlq; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.meetings_dlq (
+    id bigint NOT NULL,
+    failed_at timestamp with time zone DEFAULT now() NOT NULL,
+    job_id bigint NOT NULL,
+    payload jsonb NOT NULL,
+    group_key text,
+    inserted_at timestamp with time zone DEFAULT now() NOT NULL,
+    updated_at timestamp with time zone,
+    last_attempted_at timestamp with time zone,
+    not_visible_until timestamp with time zone,
+    attempts integer DEFAULT 0 NOT NULL,
+    last_error text,
+    priority integer DEFAULT 0 NOT NULL,
+    dedup_key text,
+    dedup_strategy text,
+    max_attempts integer,
+    parent_id bigint,
+    parent_state jsonb,
+    suspended boolean DEFAULT false NOT NULL,
+    claimed_by uuid,
+    rate_limit_key text,
+    rate_limit_prefix text,
+    concurrency_key text,
+    concurrency_prefix text,
+    rate_limit_cost double precision DEFAULT 1 NOT NULL,
+    archive_for integer
+);
+
+
+ALTER TABLE arbiter.meetings_dlq OWNER TO "wire-server";
+
+--
+-- Name: meetings_dlq_id_seq; Type: SEQUENCE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE SEQUENCE arbiter.meetings_dlq_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+ALTER SEQUENCE arbiter.meetings_dlq_id_seq OWNER TO "wire-server";
+
+--
+-- Name: meetings_dlq_id_seq; Type: SEQUENCE OWNED BY; Schema: arbiter; Owner: wire-server
+--
+
+ALTER SEQUENCE arbiter.meetings_dlq_id_seq OWNED BY arbiter.meetings_dlq.id;
+
+
+--
+-- Name: meetings_groups; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.meetings_groups (
+    group_key text NOT NULL,
+    min_priority integer DEFAULT 0 NOT NULL,
+    min_id bigint DEFAULT 0 NOT NULL,
+    job_count integer DEFAULT 0 NOT NULL,
+    in_flight_until timestamp with time zone,
+    ready_count integer DEFAULT 0 NOT NULL,
+    next_due timestamp with time zone
+);
+
+
+ALTER TABLE arbiter.meetings_groups OWNER TO "wire-server";
+
+--
+-- Name: meetings_id_seq; Type: SEQUENCE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE SEQUENCE arbiter.meetings_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+
+ALTER SEQUENCE arbiter.meetings_id_seq OWNER TO "wire-server";
+
+--
+-- Name: meetings_id_seq; Type: SEQUENCE OWNED BY; Schema: arbiter; Owner: wire-server
+--
+
+ALTER SEQUENCE arbiter.meetings_id_seq OWNED BY arbiter.meetings.id;
+
+
+--
+-- Name: meetings_results; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.meetings_results (
+    parent_id bigint NOT NULL,
+    child_id bigint NOT NULL,
+    result jsonb NOT NULL
+);
+
+
+ALTER TABLE arbiter.meetings_results OWNER TO "wire-server";
+
+--
+-- Name: schema_migrations; Type: TABLE; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TABLE arbiter.schema_migrations (
+    filename character varying(512) NOT NULL,
+    checksum character varying(32) NOT NULL,
+    executed_at timestamp without time zone DEFAULT now() NOT NULL
+);
+
+
+ALTER TABLE arbiter.schema_migrations OWNER TO "wire-server";
+
 --
 -- Name: apps; Type: TABLE; Schema: public; Owner: wire-server
 --
@@ -480,7 +1648,193 @@ CREATE TABLE public.wire_user (
 );
 
 
-ALTER TABLE public.wire_user OWNER TO "wire-server";
+ALTER TABLE public.wire_user OWNER TO "wire-server";
+
+--
+-- Name: conversations id; Type: DEFAULT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.conversations ALTER COLUMN id SET DEFAULT nextval('arbiter.conversations_id_seq'::regclass);
+
+
+--
+-- Name: conversations_archive id; Type: DEFAULT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.conversations_archive ALTER COLUMN id SET DEFAULT nextval('arbiter.conversations_archive_id_seq'::regclass);
+
+
+--
+-- Name: conversations_dlq id; Type: DEFAULT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.conversations_dlq ALTER COLUMN id SET DEFAULT nextval('arbiter.conversations_dlq_id_seq'::regclass);
+
+
+--
+-- Name: meetings id; Type: DEFAULT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.meetings ALTER COLUMN id SET DEFAULT nextval('arbiter.meetings_id_seq'::regclass);
+
+
+--
+-- Name: meetings_archive id; Type: DEFAULT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.meetings_archive ALTER COLUMN id SET DEFAULT nextval('arbiter.meetings_archive_id_seq'::regclass);
+
+
+--
+-- Name: meetings_dlq id; Type: DEFAULT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.meetings_dlq ALTER COLUMN id SET DEFAULT nextval('arbiter.meetings_dlq_id_seq'::regclass);
+
+
+--
+-- Name: arbiter_concurrency arbiter_concurrency_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.arbiter_concurrency
+    ADD CONSTRAINT arbiter_concurrency_pkey PRIMARY KEY (concurrency_key);
+
+
+--
+-- Name: arbiter_concurrency_policies arbiter_concurrency_policies_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.arbiter_concurrency_policies
+    ADD CONSTRAINT arbiter_concurrency_policies_pkey PRIMARY KEY (prefix_id);
+
+
+--
+-- Name: arbiter_gates arbiter_gates_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.arbiter_gates
+    ADD CONSTRAINT arbiter_gates_pkey PRIMARY KEY (task_name);
+
+
+--
+-- Name: arbiter_queues arbiter_queues_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.arbiter_queues
+    ADD CONSTRAINT arbiter_queues_pkey PRIMARY KEY (queue_name);
+
+
+--
+-- Name: arbiter_rate_limit_policies arbiter_rate_limit_policies_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.arbiter_rate_limit_policies
+    ADD CONSTRAINT arbiter_rate_limit_policies_pkey PRIMARY KEY (prefix_id);
+
+
+--
+-- Name: arbiter_rate_limits arbiter_rate_limits_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.arbiter_rate_limits
+    ADD CONSTRAINT arbiter_rate_limits_pkey PRIMARY KEY (rate_limit_key);
+
+
+--
+-- Name: arbiter_workers arbiter_workers_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.arbiter_workers
+    ADD CONSTRAINT arbiter_workers_pkey PRIMARY KEY (worker_id);
+
+
+--
+-- Name: conversations_archive conversations_archive_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.conversations_archive
+    ADD CONSTRAINT conversations_archive_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: conversations_dlq conversations_dlq_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.conversations_dlq
+    ADD CONSTRAINT conversations_dlq_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: conversations_groups conversations_groups_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.conversations_groups
+    ADD CONSTRAINT conversations_groups_pkey PRIMARY KEY (group_key);
+
+
+--
+-- Name: conversations conversations_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.conversations
+    ADD CONSTRAINT conversations_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: conversations_results conversations_results_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.conversations_results
+    ADD CONSTRAINT conversations_results_pkey PRIMARY KEY (parent_id, child_id);
+
+
+--
+-- Name: cron_schedules cron_schedules_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.cron_schedules
+    ADD CONSTRAINT cron_schedules_pkey PRIMARY KEY (name);
+
+
+--
+-- Name: meetings_archive meetings_archive_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.meetings_archive
+    ADD CONSTRAINT meetings_archive_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: meetings_dlq meetings_dlq_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.meetings_dlq
+    ADD CONSTRAINT meetings_dlq_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: meetings_groups meetings_groups_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.meetings_groups
+    ADD CONSTRAINT meetings_groups_pkey PRIMARY KEY (group_key);
+
+
+--
+-- Name: meetings meetings_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.meetings
+    ADD CONSTRAINT meetings_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: meetings_results meetings_results_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.meetings_results
+    ADD CONSTRAINT meetings_results_pkey PRIMARY KEY (parent_id, child_id);
+
 
 --
 -- Name: apps apps_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server
@@ -674,6 +2028,265 @@ ALTER TABLE ONLY public.wire_user
     ADD CONSTRAINT wire_user_pkey PRIMARY KEY (id);
 
 
+--
+-- Name: conversations_adminless_team_id_idx; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX conversations_adminless_team_id_idx ON arbiter.conversations USING btree (((payload #>> '{data,team_id}'::text[]))) WHERE ((claimed_by IS NULL) AND ((payload ->> 'type'::text) = ANY (ARRAY['adminless_setup'::text, 'adminless_deletion'::text, 'adminless_reminder'::text])));
+
+
+--
+-- Name: idx_conversations_archive_completed_at; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_archive_completed_at ON arbiter.conversations_archive USING btree (completed_at DESC);
+
+
+--
+-- Name: idx_conversations_archive_expires_at; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_archive_expires_at ON arbiter.conversations_archive USING btree (archive_expires_at);
+
+
+--
+-- Name: idx_conversations_archive_group_key; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_archive_group_key ON arbiter.conversations_archive USING btree (group_key);
+
+
+--
+-- Name: idx_conversations_archive_job_id; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_archive_job_id ON arbiter.conversations_archive USING btree (job_id);
+
+
+--
+-- Name: idx_conversations_archive_parent_id; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_archive_parent_id ON arbiter.conversations_archive USING btree (parent_id) WHERE (parent_id IS NOT NULL);
+
+
+--
+-- Name: idx_conversations_cancel_requested; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_cancel_requested ON arbiter.conversations USING btree (id) WHERE (cancel_requested_at IS NOT NULL);
+
+
+--
+-- Name: idx_conversations_concurrency; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_concurrency ON arbiter.conversations USING btree (concurrency_key) WHERE (concurrency_key IS NOT NULL);
+
+
+--
+-- Name: idx_conversations_dedup_key; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE UNIQUE INDEX idx_conversations_dedup_key ON arbiter.conversations USING btree (dedup_key) WHERE (dedup_key IS NOT NULL);
+
+
+--
+-- Name: idx_conversations_dlq_failed_at; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_dlq_failed_at ON arbiter.conversations_dlq USING btree (failed_at DESC);
+
+
+--
+-- Name: idx_conversations_dlq_group_key; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_dlq_group_key ON arbiter.conversations_dlq USING btree (group_key);
+
+
+--
+-- Name: idx_conversations_dlq_parent_id; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_dlq_parent_id ON arbiter.conversations_dlq USING btree (parent_id) WHERE (parent_id IS NOT NULL);
+
+
+--
+-- Name: idx_conversations_group_key; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_group_key ON arbiter.conversations USING btree (group_key, priority, id) WHERE (group_key IS NOT NULL);
+
+
+--
+-- Name: idx_conversations_groups_next_due; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_groups_next_due ON arbiter.conversations_groups USING btree (next_due) WHERE (next_due IS NOT NULL);
+
+
+--
+-- Name: idx_conversations_groups_ranking; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_groups_ranking ON arbiter.conversations_groups USING btree (min_priority, min_id) WHERE ((ready_count > 0) AND (in_flight_until IS NULL));
+
+
+--
+-- Name: idx_conversations_parent_id; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_parent_id ON arbiter.conversations USING btree (parent_id) WHERE (parent_id IS NOT NULL);
+
+
+--
+-- Name: idx_conversations_throttled; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_throttled ON arbiter.conversations USING btree (rate_limit_prefix, rate_limit_key) WHERE (throttled_until IS NOT NULL);
+
+
+--
+-- Name: idx_conversations_ungrouped_due; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_ungrouped_due ON arbiter.conversations USING btree (not_visible_until) WHERE ((group_key IS NULL) AND (not_visible_until IS NOT NULL) AND (NOT suspended));
+
+
+--
+-- Name: idx_conversations_ungrouped_ready_ranking; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_conversations_ungrouped_ready_ranking ON arbiter.conversations USING btree (priority, id) WHERE ((group_key IS NULL) AND (not_visible_until IS NULL) AND (NOT suspended));
+
+
+--
+-- Name: idx_meetings_archive_completed_at; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_archive_completed_at ON arbiter.meetings_archive USING btree (completed_at DESC);
+
+
+--
+-- Name: idx_meetings_archive_expires_at; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_archive_expires_at ON arbiter.meetings_archive USING btree (archive_expires_at);
+
+
+--
+-- Name: idx_meetings_archive_group_key; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_archive_group_key ON arbiter.meetings_archive USING btree (group_key);
+
+
+--
+-- Name: idx_meetings_archive_job_id; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_archive_job_id ON arbiter.meetings_archive USING btree (job_id);
+
+
+--
+-- Name: idx_meetings_archive_parent_id; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_archive_parent_id ON arbiter.meetings_archive USING btree (parent_id) WHERE (parent_id IS NOT NULL);
+
+
+--
+-- Name: idx_meetings_cancel_requested; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_cancel_requested ON arbiter.meetings USING btree (id) WHERE (cancel_requested_at IS NOT NULL);
+
+
+--
+-- Name: idx_meetings_concurrency; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_concurrency ON arbiter.meetings USING btree (concurrency_key) WHERE (concurrency_key IS NOT NULL);
+
+
+--
+-- Name: idx_meetings_dedup_key; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE UNIQUE INDEX idx_meetings_dedup_key ON arbiter.meetings USING btree (dedup_key) WHERE (dedup_key IS NOT NULL);
+
+
+--
+-- Name: idx_meetings_dlq_failed_at; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_dlq_failed_at ON arbiter.meetings_dlq USING btree (failed_at DESC);
+
+
+--
+-- Name: idx_meetings_dlq_group_key; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_dlq_group_key ON arbiter.meetings_dlq USING btree (group_key);
+
+
+--
+-- Name: idx_meetings_dlq_parent_id; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_dlq_parent_id ON arbiter.meetings_dlq USING btree (parent_id) WHERE (parent_id IS NOT NULL);
+
+
+--
+-- Name: idx_meetings_group_key; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_group_key ON arbiter.meetings USING btree (group_key, priority, id) WHERE (group_key IS NOT NULL);
+
+
+--
+-- Name: idx_meetings_groups_next_due; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_groups_next_due ON arbiter.meetings_groups USING btree (next_due) WHERE (next_due IS NOT NULL);
+
+
+--
+-- Name: idx_meetings_groups_ranking; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_groups_ranking ON arbiter.meetings_groups USING btree (min_priority, min_id) WHERE ((ready_count > 0) AND (in_flight_until IS NULL));
+
+
+--
+-- Name: idx_meetings_parent_id; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_parent_id ON arbiter.meetings USING btree (parent_id) WHERE (parent_id IS NOT NULL);
+
+
+--
+-- Name: idx_meetings_throttled; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_throttled ON arbiter.meetings USING btree (rate_limit_prefix, rate_limit_key) WHERE (throttled_until IS NOT NULL);
+
+
+--
+-- Name: idx_meetings_ungrouped_due; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_ungrouped_due ON arbiter.meetings USING btree (not_visible_until) WHERE ((group_key IS NULL) AND (not_visible_until IS NOT NULL) AND (NOT suspended));
+
+
+--
+-- Name: idx_meetings_ungrouped_ready_ranking; Type: INDEX; Schema: arbiter; Owner: wire-server
+--
+
+CREATE INDEX idx_meetings_ungrouped_ready_ranking ON arbiter.meetings USING btree (priority, id) WHERE ((group_key IS NULL) AND (not_visible_until IS NULL) AND (NOT suspended));
+
+
 --
 -- Name: asset_user_id_idx; Type: INDEX; Schema: public; Owner: wire-server
 --
@@ -821,6 +2434,62 @@ CREATE INDEX user_group_member_user_id_idx ON public.user_group_member USING btr
 CREATE INDEX wire_user_service_idx ON public.wire_user USING btree (provider, service);
 
 
+--
+-- Name: conversations conversations_notify_trigger; Type: TRIGGER; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TRIGGER conversations_notify_trigger AFTER INSERT ON arbiter.conversations FOR EACH ROW EXECUTE FUNCTION arbiter.notify_conversations_created();
+
+
+--
+-- Name: conversations maintain_conversations_groups_delete; Type: TRIGGER; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TRIGGER maintain_conversations_groups_delete AFTER DELETE ON arbiter.conversations REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE FUNCTION arbiter.maintain_conversations_groups_delete();
+
+
+--
+-- Name: conversations maintain_conversations_groups_insert; Type: TRIGGER; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TRIGGER maintain_conversations_groups_insert AFTER INSERT ON arbiter.conversations REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE FUNCTION arbiter.maintain_conversations_groups_insert();
+
+
+--
+-- Name: conversations maintain_conversations_groups_update; Type: TRIGGER; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TRIGGER maintain_conversations_groups_update AFTER UPDATE ON arbiter.conversations REFERENCING OLD TABLE AS old_table NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE FUNCTION arbiter.maintain_conversations_groups_update();
+
+
+--
+-- Name: meetings maintain_meetings_groups_delete; Type: TRIGGER; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TRIGGER maintain_meetings_groups_delete AFTER DELETE ON arbiter.meetings REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE FUNCTION arbiter.maintain_meetings_groups_delete();
+
+
+--
+-- Name: meetings maintain_meetings_groups_insert; Type: TRIGGER; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TRIGGER maintain_meetings_groups_insert AFTER INSERT ON arbiter.meetings REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE FUNCTION arbiter.maintain_meetings_groups_insert();
+
+
+--
+-- Name: meetings maintain_meetings_groups_update; Type: TRIGGER; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TRIGGER maintain_meetings_groups_update AFTER UPDATE ON arbiter.meetings REFERENCING OLD TABLE AS old_table NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE FUNCTION arbiter.maintain_meetings_groups_update();
+
+
+--
+-- Name: meetings meetings_notify_trigger; Type: TRIGGER; Schema: arbiter; Owner: wire-server
+--
+
+CREATE TRIGGER meetings_notify_trigger AFTER INSERT ON arbiter.meetings FOR EACH ROW EXECUTE FUNCTION arbiter.notify_meetings_created();
+
+
 --
 -- Name: wire_user update_user_updated_at; Type: TRIGGER; Schema: public; Owner: wire-server
 --
@@ -828,6 +2497,22 @@ CREATE INDEX wire_user_service_idx ON public.wire_user USING btree (provider, se
 CREATE TRIGGER update_user_updated_at BEFORE UPDATE ON public.wire_user FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
 
 
+--
+-- Name: conversations_results conversations_results_parent_id_fkey; Type: FK CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.conversations_results
+    ADD CONSTRAINT conversations_results_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES arbiter.conversations(id) ON DELETE CASCADE;
+
+
+--
+-- Name: meetings_results meetings_results_parent_id_fkey; Type: FK CONSTRAINT; Schema: arbiter; Owner: wire-server
+--
+
+ALTER TABLE ONLY arbiter.meetings_results
+    ADD CONSTRAINT meetings_results_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES arbiter.meetings(id) ON DELETE CASCADE;
+
+
 --
 -- Name: bot_conv bot_conv_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: wire-server
 --

From 151e2994f201a903a8df0549436503945fca6b0d Mon Sep 17 00:00:00 2001
From: Matthias Fischmann 
Date: Thu, 20 Aug 2026 12:27:22 +0200
Subject: [PATCH 098/113] [WPB-28132] Fix openapi3 docs for oauth scopes.
 (#5457)

Also introduces unit test to find deviations between oauth scope rules and docs.
---
 ...B-28132-fix-openapi3-docs-for-oauth-scopes |   1 +
 libs/wire-api/default.nix                     |   7 +
 libs/wire-api/src/Wire/API/OAuth.hs           |   2 +-
 libs/wire-api/src/Wire/API/Routes/Public.hs   |  25 +-
 .../API/Routes/Public/Galley/Conversation.hs  |   2 +
 .../src/Wire/API/Routes/Public/Swagger.hs     |  75 +++++
 .../unit/Test/Wire/API/Routes/OAuthScopes.hs  | 300 ++++++++++++++++++
 libs/wire-api/test/unit/Test/Wire/API/Run.hs  |   2 +
 libs/wire-api/wire-api.cabal                  |   6 +
 nix/wire-server.nix                           |  17 +
 services/brig/src/Brig/API/Public.hs          |  25 +-
 11 files changed, 428 insertions(+), 34 deletions(-)
 create mode 100644 changelog.d/3-bug-fixes/WPB-28132-fix-openapi3-docs-for-oauth-scopes
 create mode 100644 libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs
 create mode 100644 libs/wire-api/test/unit/Test/Wire/API/Routes/OAuthScopes.hs

diff --git a/changelog.d/3-bug-fixes/WPB-28132-fix-openapi3-docs-for-oauth-scopes b/changelog.d/3-bug-fixes/WPB-28132-fix-openapi3-docs-for-oauth-scopes
new file mode 100644
index 00000000000..a732d0fc5e9
--- /dev/null
+++ b/changelog.d/3-bug-fixes/WPB-28132-fix-openapi3-docs-for-oauth-scopes
@@ -0,0 +1 @@
+Fix openapi3 docs for oauth scopes.
diff --git a/libs/wire-api/default.nix b/libs/wire-api/default.nix
index ad6aae69343..cb9e759f8f9 100644
--- a/libs/wire-api/default.nix
+++ b/libs/wire-api/default.nix
@@ -41,6 +41,7 @@
 , errors
 , extended
 , extra
+, file-embed
 , filepath
 , generics-sop
 , ghc-prim
@@ -106,6 +107,7 @@
 , tasty-hspec
 , tasty-hunit
 , tasty-quickcheck
+, template-haskell
 , text
 , these
 , time
@@ -125,6 +127,7 @@
 , wai-websockets
 , websockets
 , wire-message-proto-lens
+, yaml
 , zauth
 }:
 mkDerivation {
@@ -257,6 +260,7 @@ mkDerivation {
     crypton-pem
     currency-codes
     data-default
+    file-embed
     filepath
     hex
     hspec
@@ -274,6 +278,7 @@ mkDerivation {
     QuickCheck
     ram
     random
+    regex-tdfa
     saml2-web-sso
     schema-profunctor
     servant
@@ -283,6 +288,7 @@ mkDerivation {
     tasty-hspec
     tasty-hunit
     tasty-quickcheck
+    template-haskell
     text
     time
     types-common
@@ -292,6 +298,7 @@ mkDerivation {
     vector
     wai
     wire-message-proto-lens
+    yaml
   ];
   license = lib.meta.getLicenseFromSpdxId "AGPL-3.0-only";
 }
diff --git a/libs/wire-api/src/Wire/API/OAuth.hs b/libs/wire-api/src/Wire/API/OAuth.hs
index a3ff2db1537..6ff71d5cacd 100644
--- a/libs/wire-api/src/Wire/API/OAuth.hs
+++ b/libs/wire-api/src/Wire/API/OAuth.hs
@@ -199,7 +199,7 @@ data OAuthScope
   | ReadSelf
   | WriteConversations
   | WriteConversationsCode
-  deriving (Eq, Show, Generic, Ord)
+  deriving (Eq, Show, Generic, Ord, Bounded, Enum)
   deriving (Arbitrary) via (GenericUniform OAuthScope)
 
 class IsOAuthScope scope where
diff --git a/libs/wire-api/src/Wire/API/Routes/Public.hs b/libs/wire-api/src/Wire/API/Routes/Public.hs
index 3bc651cad8a..cacd78420dd 100644
--- a/libs/wire-api/src/Wire/API/Routes/Public.hs
+++ b/libs/wire-api/src/Wire/API/Routes/Public.hs
@@ -31,6 +31,7 @@ module Wire.API.Routes.Public
     ZProvider,
     ZAccess,
     DescriptionOAuthScope,
+    renderOAuthScope,
     ZHostOpt,
     ZHostValue,
     ZAuthServant,
@@ -352,17 +353,19 @@ instance
 
 addScopeDescription :: forall scope. (OAuth.IsOAuthScope scope) => OpenApi -> OpenApi
 addScopeDescription =
-  allOperations
-    . description
-    %~ Just
-      . ( <>
-            "\nOAuth scope: `"
-              <> ( decodeUtf8With lenientDecode . toStrict . toByteString $
-                     OAuth.toOAuthScope @scope
-                 )
-              <> "`"
-        )
-      . fold
+  allOperations . description %~ Just . (<> renderOAuthScope (OAuth.toOAuthScope @scope)) . fold
+
+-- | The snippet 'DescriptionOAuthScope' appends to an operation description.
+--
+-- This is enforced in @charts/nginz/values.yaml@ (search for
+-- @oauth_scope@).  Mismatches are caught in
+-- @Test.Wire.API.Routes.OAuthScopes@.  (This function is exported for
+-- that test only.)
+renderOAuthScope :: OAuth.OAuthScope -> Text
+renderOAuthScope scope =
+  "\nOAuth scope: `"
+    <> (decodeUtf8With lenientDecode . toStrict . toByteString $ scope)
+    <> "`"
 
 instance (HasServer api ctx) => HasServer (DescriptionOAuthScope scope :> api) ctx where
   type ServerT (DescriptionOAuthScope scope :> api) m = ServerT api m
diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs
index a353a2b630b..fc5ac034f01 100644
--- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs
+++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs
@@ -534,6 +534,7 @@ type ConversationAPI =
     :<|> Named
            "create-group-conversation"
            ( Summary "Create a new conversation"
+               :> DescriptionOAuthScope 'WriteConversations
                :> From 'V16
                :> CanThrow 'ConvAccessDenied
                :> CanThrow 'MLSNonEmptyMemberList
@@ -1150,6 +1151,7 @@ type ConversationAPI =
     :<|> Named
            "get-code"
            ( Summary "Get existing conversation code"
+               :> DescriptionOAuthScope 'WriteConversationsCode
                :> CanThrow 'CodeNotFound
                :> CanThrow 'ConvAccessDenied
                :> CanThrow 'ConvNotFound
diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs b/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs
new file mode 100644
index 00000000000..31bcc202fd4
--- /dev/null
+++ b/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs
@@ -0,0 +1,75 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+-- | The swagger docs for the public API of the development version.
+--
+-- Older versions are frozen and served from the pregenerated JSON files in
+-- @services/brig/docs/@; this is the only version that is still assembled from
+-- the routing tables.  It lives here rather than in brig so that tests in this
+-- package can get at it -- see @Test.Wire.API.Routes.OAuthScopes@.
+module Wire.API.Routes.Public.Swagger
+  ( devVersion,
+    devVersionSwagger,
+  )
+where
+
+import Control.Lens ((.~))
+import Data.OpenApi qualified as S
+import Imports
+import Servant.API (toUrlPiece)
+import Wire.API.Routes.API (serviceSwagger)
+import Wire.API.Routes.Public.Brig (BrigAPITag)
+import Wire.API.Routes.Public.Brig.OAuth (OAuthAPITag)
+import Wire.API.Routes.Public.Cannon (CannonAPITag)
+import Wire.API.Routes.Public.Cargohold (CargoholdAPITag)
+import Wire.API.Routes.Public.Galley (GalleyAPITag)
+import Wire.API.Routes.Public.Gundeck (GundeckAPITag)
+import Wire.API.Routes.Public.Proxy (ProxyAPITag)
+import Wire.API.Routes.Public.Spar (SparAPITag)
+import Wire.API.Routes.Version
+import Wire.API.SwaggerHelper (cleanupSwagger)
+
+-- | The version 'devVersionSwagger' describes.  Must stay in sync with the type
+-- level @\'V17@ below; there is no way to tie the two together, since the
+-- 'S.OpenApi' has to be assembled at a statically known version.
+devVersion :: Version
+devVersion =
+  if maxBound == V17
+    then maxBound
+    else
+      -- if you get this error, you also need to update the version literals below.
+      error "libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs#devVersion: please update to latest api version!"
+
+-- | Note that brig additionally sets @info.description@ from
+-- @services/brig/docs/swagger.md@, which cannot move here: it is embedded
+-- relative to the brig package.  'cleanupSwagger' does not touch
+-- @info.description@, so setting it afterwards is equivalent.
+devVersionSwagger :: S.OpenApi
+devVersionSwagger =
+  ( serviceSwagger @VersionAPITag @'V17
+      <> serviceSwagger @BrigAPITag @'V17
+      <> serviceSwagger @GalleyAPITag @'V17
+      <> serviceSwagger @SparAPITag @'V17
+      <> serviceSwagger @CargoholdAPITag @'V17
+      <> serviceSwagger @CannonAPITag @'V17
+      <> serviceSwagger @GundeckAPITag @'V17
+      <> serviceSwagger @ProxyAPITag @'V17
+      <> serviceSwagger @OAuthAPITag @'V17
+  )
+    & S.info . S.title .~ "Wire-Server API"
+    & S.servers .~ [S.Server ("/" <> toUrlPiece devVersion) Nothing mempty]
+    & cleanupSwagger
diff --git a/libs/wire-api/test/unit/Test/Wire/API/Routes/OAuthScopes.hs b/libs/wire-api/test/unit/Test/Wire/API/Routes/OAuthScopes.hs
new file mode 100644
index 00000000000..f75de6f9d07
--- /dev/null
+++ b/libs/wire-api/test/unit/Test/Wire/API/Routes/OAuthScopes.hs
@@ -0,0 +1,300 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+-- | Two independent places declare which OAuth scope an endpoint needs, and
+-- nothing keeps them in sync:
+--
+-- 1. @charts/nginz/values.yaml@ -- @oauth_scope:@ on an upstream entry.  This is
+--    what is actually /enforced/: nginz rejects OAuth tokens without the scope.
+-- 2. The servant routing tables -- 'Wire.API.Routes.Public.DescriptionOAuthScope'.
+--    This is only /documentation/: it appends a line to the endpoint description
+--    in the swagger docs and has no effect on request handling.
+--
+-- Forgetting (2) while doing (1) -- or, more commonly, adding a new version of an
+-- endpoint that is already covered by (1) and not carrying the annotation over --
+-- silently produces endpoints that reject OAuth tokens for a scope documented
+-- nowhere.  This module compares the two for the development version, which is
+-- the only one still assembled from the routing tables.
+module Test.Wire.API.Routes.OAuthScopes (tests) where
+
+import Data.Aeson qualified as A
+import Data.Aeson.Key qualified as Key
+import Data.Aeson.KeyMap qualified as KeyMap
+import Data.ByteString.Conversion (toByteString')
+import Data.FileEmbed (embedFile, makeRelativeToProject)
+import Data.Map qualified as Map
+import Data.Set qualified as Set
+import Data.Text qualified as T
+import Data.Text.Encoding qualified as T
+import Data.Yaml qualified as Yaml
+import Imports
+import Language.Haskell.TH (runIO)
+import Servant.API (toUrlPiece)
+import Test.Tasty
+import Test.Tasty.HUnit
+import Text.Regex.TDFA ((=~))
+import Wire.API.OAuth (OAuthScope)
+import Wire.API.Routes.Public (renderOAuthScope)
+import Wire.API.Routes.Public.Swagger (devVersion, devVersionSwagger)
+import Wire.API.Routes.Version
+
+tests :: TestTree
+tests =
+  testGroup
+    "OAuth scopes (charts/nginz/values.yaml vs. swagger docs)"
+    [ testCase "nginz path patterns avoid PCRE-only constructs" testPatternVocabulary,
+      testCase "every nginz oauth_scope names a real scope" testScopeNamesAreReal,
+      testCase "enforced scopes and documented scopes agree" testScopesAgree
+    ]
+
+--------------------------------------------------------------------------------
+-- what nginz enforces
+
+-- | The locations nginz emits, in the order it emits them.
+--
+-- @charts/nginz/templates/_helpers.tpl@ merges @upstreams@ (minus
+-- @ignored_upstreams@) with the enabled @extra_upstreams@ into a single map, and
+-- @templates/conf/_nginx.conf.tpl@ ranges over that map.  Go template map
+-- iteration is sorted by key, so upstreams are emitted alphabetically and only
+-- the list within one upstream keeps its document order -- which is exactly what
+-- decoding into a 'Map' and taking 'Map.elems' gives us.
+newtype NginzLocations = NginzLocations [Location]
+
+data Location = Location
+  { locPattern :: Text,
+    locScope :: Maybe Text
+  }
+
+-- | The scopes that get an OAuth token past nginz to this endpoint.
+--
+-- Empty when nginz requires no scope, and also when it requires one
+-- not in 'Wire.API.OAuth.OAuthScopes'.  Mistyped scope names are
+-- caught by 'testScopeNamesAreReal'.
+enforcedScopes :: Text -> Text -> Set Text
+enforcedScopes method path =
+  fromMaybe Set.empty $ do
+    loc <- find (`locationMatches` path) nginzLocations
+    base <- locScope loc
+    pure . Set.intersection grantableScopes . Set.fromList $
+      [tier <> ":" <> base | tier <- methodScopeTiers method]
+
+nginzLocations :: [Location]
+nginzLocations =
+  case Yaml.decodeEither' nginzValues of
+    Left e -> error $ "charts/nginz/values.yaml: " <> Yaml.prettyPrintParseException e
+    Right (NginzLocations ls) -> ls
+
+-- | @charts\/nginz\/values.yaml@, embedded at compile time.
+--
+-- Under nix only this package's own directory is copied into the build sandbox,
+-- so @nix\/wire-server.nix@ splices the chart into @test\/unit\/generated\/@ and
+-- we prefer that copy; a plain cabal build has the whole repository checked out
+-- and reads the real file instead.  The lookup is inline because a top-level
+-- splice cannot call a function defined in the same module.
+nginzValues :: ByteString
+nginzValues =
+  $( do
+       spliced <- makeRelativeToProject "test/unit/generated/nginz-values.yaml"
+       spliced' <- runIO (doesFileExist spliced)
+       embedFile
+         =<< if spliced'
+           then pure spliced
+           else makeRelativeToProject "../../charts/nginz/values.yaml"
+   )
+
+instance A.FromJSON NginzLocations where
+  parseJSON = A.withObject "charts/nginz/values.yaml" $ \top -> do
+    conf <- top A..: "nginx_conf"
+    ups <- conf A..: "upstreams"
+    extra <- conf A..:? "extra_upstreams" A..!= Map.empty
+    ignored <- conf A..:? "ignored_upstreams" A..!= []
+    enabled <- conf A..:? "enabled_extra_upstreams" A..!= []
+    pure
+      . NginzLocations
+      . concat
+      . Map.elems
+      $ Map.withoutKeys ups (Set.fromList (ignored :: [Text]))
+        <> Map.restrictKeys extra (Set.fromList (enabled :: [Text]))
+
+instance A.FromJSON Location where
+  parseJSON = A.withObject "nginz upstream entry" $ \o ->
+    Location <$> o A..: "path" <*> o A..:? "oauth_scope"
+
+-- | Does this location capture that path?  nginx anchors regex locations at the
+-- start of the URI but not at the end, so a pattern without a trailing @$@
+-- matches every path with that prefix.
+--
+-- The patterns are PCRE (that is what nginx uses) and we match them with
+-- regex-tdfa, which is POSIX ERE.  The two agree on the handful of constructs
+-- values.yaml actually uses; 'testPatternVocabulary' keeps it that way.
+locationMatches :: Location -> Text -> Bool
+locationMatches loc path =
+  T.unpack (probePath path) =~ T.unpack ("^" <> locPattern loc)
+
+-- | @/conversations/{cnv}/code@ becomes @/conversations/PARAM/code@: the literal
+-- segments still have to match, the captures must not.
+probePath :: Text -> Text
+probePath t =
+  let (before, rest) = T.breakOn "{" t
+   in if T.null rest
+        then before
+        else before <> "PARAM" <> probePath (T.drop 1 (T.dropWhile (/= '}') rest))
+
+pcreOnlyConstructs :: [Text]
+pcreOnlyConstructs = ["(?", "\\", "{", "*?", "+?"]
+
+-- | @oauth_scope: foo@ in values.yaml names a scope without a tier; libzauth
+-- decides which tiers satisfy it from the request method.  See @verify_scope@ in
+-- @libs/libzauth/libzauth/src/oauth.rs@
+methodScopeTiers :: Text -> [Text]
+methodScopeTiers = \case
+  "GET" -> ["read", "write", "admin"]
+  "POST" -> ["write", "admin"]
+  "PUT" -> ["write", "admin"]
+  "DELETE" -> ["admin"]
+  _ -> []
+
+--------------------------------------------------------------------------------
+-- what the swagger docs claim
+
+documentedScopes :: Text -> Set Text
+documentedScopes descr =
+  Set.fromList
+    [ T.decodeUtf8 (toByteString' scope)
+    | scope <- [minBound .. maxBound] :: [OAuthScope],
+      -- Recognise a documented scope by the very string
+      -- 'renderOAuthScope' produces, so that the two cannot drift
+      -- apart.
+      renderOAuthScope scope `T.isInfixOf` descr
+    ]
+
+httpMethods :: [Text]
+httpMethods = ["GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"]
+
+-- | @(path, method, description)@ for every operation in a swagger document.
+operations :: A.Value -> [(Text, Text, Text)]
+operations doc = do
+  paths <- maybeToList (object doc >>= KeyMap.lookup "paths" >>= object)
+  (path, pathItem) <- KeyMap.toList paths
+  item <- maybeToList (object pathItem)
+  (method, op) <- KeyMap.toList item
+  let method' = T.toUpper (Key.toText method)
+  guard (method' `elem` httpMethods)
+  pure (Key.toText path, method', fromMaybe "" (object op >>= KeyMap.lookup "description" >>= string))
+  where
+    object = \case A.Object o -> Just o; _ -> Nothing
+    string = \case A.String s -> Just s; _ -> Nothing
+
+--------------------------------------------------------------------------------
+-- the comparison
+
+-- | 'Finding's are interesting iff @fEnforced /= fDocumented@.
+data Finding = Finding
+  { fVersion :: Version,
+    fMethod :: Text,
+    fPath :: Text,
+    fEnforced :: Set Text,
+    fDocumented :: Set Text
+  }
+
+-- | The scopes brig can actually issue.  Anything else is not a scope at all:
+-- 'Wire.API.OAuth.OAuthScopes' fails to parse it, and yields the empty scope set
+-- for the whole request.
+grantableScopes :: Set Text
+grantableScopes =
+  Set.fromList [T.decodeUtf8 (toByteString' s) | s <- [minBound .. maxBound] :: [OAuthScope]]
+
+renderFinding :: Finding -> Text
+renderFinding f =
+  T.intercalate
+    "\t"
+    [ toUrlPiece (fVersion f),
+      fMethod f,
+      fPath f,
+      renderScopes (fEnforced f),
+      renderScopes (fDocumented f)
+    ]
+  where
+    renderScopes s
+      | Set.null s = "-"
+      | otherwise = T.intercalate " " (Set.toAscList s)
+
+findings :: [Finding]
+findings =
+  [ Finding devVersion method path enforced documented
+  | (path, method, descr) <- operations (A.toJSON devVersionSwagger),
+    let enforced = enforcedScopes method path,
+    let documented = documentedScopes descr,
+    enforced /= documented
+  ]
+
+--------------------------------------------------------------------------------
+-- the actual tests
+
+testPatternVocabulary :: Assertion
+testPatternVocabulary =
+  for_ nginzLocations $ \loc ->
+    for_ pcreOnlyConstructs $ \bad ->
+      when (bad `T.isInfixOf` locPattern loc) $
+        assertFailure . T.unpack $
+          "charts/nginz/values.yaml: the path pattern "
+            <> locPattern loc
+            <> " uses '"
+            <> bad
+            <> "', which nginx reads as PCRE but this test matches with regex-tdfa, "
+            <> "i.e. POSIX ERE.  The two may disagree, which would be bad."
+
+-- | 'enforcedScopes' ignores scopes brig cannot issue, so a typo in an
+-- @oauth_scope:@ would otherwise make every endpoint under it drop silently out
+-- of the comparison.  Require that each name is usable at some tier.
+testScopeNamesAreReal :: Assertion
+testScopeNamesAreReal =
+  for_ (nub (mapMaybe locScope nginzLocations)) $ \base ->
+    unless (any (\tier -> (tier <> ":" <> base) `Set.member` grantableScopes) ["read", "write", "admin"]) $
+      assertFailure . T.unpack $
+        "charts/nginz/values.yaml: 'oauth_scope: "
+          <> base
+          <> "' matches no scope in Wire.API.OAuth.OAuthScope at any tier, so no "
+          <> "OAuth token can ever satisfy it and every endpoint under that "
+          <> "location is closed to OAuth.\nEither fix the name, or add the scope."
+
+testScopesAgree :: Assertion
+testScopesAgree = do
+  unless (Set.null actual) . assertFailure . T.unpack . T.unlines $
+    [ "OAuth scope declarations are out of sync.",
+      "",
+      "Columns: version, method, path, accepted by nginz, documented in swagger.",
+      "'-' means no scope. A finding means those last two disagree:",
+      "",
+      "  enforced but not documented  charts/nginz/values.yaml requires a scope the",
+      "                               swagger docs do not mention -- most likely a",
+      "                               missing DescriptionOAuthScope in the routing",
+      "                               table, e.g. on a newly added version of an",
+      "                               endpoint that already had one.",
+      "  documented but not enforced  the swagger docs promise a scope nginz does not",
+      "                               require -- a stale annotation, or a missing",
+      "                               oauth_scope: in charts/nginz/values.yaml.",
+      ""
+    ]
+      <> section "deviations:" actual
+  where
+    actual = Set.fromList (renderFinding <$> findings)
+    section title xs
+      | Set.null xs = []
+      | otherwise = ["  " <> title] <> (("    " <>) <$> Set.toAscList xs) <> [""]
diff --git a/libs/wire-api/test/unit/Test/Wire/API/Run.hs b/libs/wire-api/test/unit/Test/Wire/API/Run.hs
index 6a909534def..3e30589b08c 100644
--- a/libs/wire-api/test/unit/Test/Wire/API/Run.hs
+++ b/libs/wire-api/test/unit/Test/Wire/API/Run.hs
@@ -34,6 +34,7 @@ import Test.Wire.API.Roundtrip.HttpApiData qualified as Roundtrip.HttpApiData
 import Test.Wire.API.Roundtrip.MLS qualified as Roundtrip.MLS
 import Test.Wire.API.Roundtrip.PostgresMarshall as PostgresMarshall
 import Test.Wire.API.Routes qualified as Routes
+import Test.Wire.API.Routes.OAuthScopes qualified as Routes.OAuthScopes
 import Test.Wire.API.Routes.Version qualified as Routes.Version
 import Test.Wire.API.Routes.Version.Wai qualified as Routes.Version.Wai
 import Test.Wire.API.Swagger qualified as Swagger
@@ -63,6 +64,7 @@ main =
         Swagger.tests,
         Roundtrip.CSV.tests,
         Routes.tests,
+        Routes.OAuthScopes.tests,
         Conversation.tests,
         Meeting.tests,
         MLS.tests,
diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal
index 73f45dd5eb2..d6d83c2fd4b 100644
--- a/libs/wire-api/wire-api.cabal
+++ b/libs/wire-api/wire-api.cabal
@@ -219,6 +219,7 @@ library
     Wire.API.Routes.Public.Gundeck
     Wire.API.Routes.Public.Proxy
     Wire.API.Routes.Public.Spar
+    Wire.API.Routes.Public.Swagger
     Wire.API.Routes.Public.Util
     Wire.API.Routes.QualifiedCapture
     Wire.API.Routes.SpecialiseToVersion
@@ -726,6 +727,7 @@ test-suite wire-api-tests
     Test.Wire.API.Roundtrip.MLS
     Test.Wire.API.Roundtrip.PostgresMarshall
     Test.Wire.API.Routes
+    Test.Wire.API.Routes.OAuthScopes
     Test.Wire.API.Routes.Version
     Test.Wire.API.Routes.Version.Wai
     Test.Wire.API.Run
@@ -751,6 +753,7 @@ test-suite wire-api-tests
     , containers             >=0.5
     , crypton
     , data-default
+    , file-embed
     , filepath
     , hex
     , hspec
@@ -764,6 +767,7 @@ test-suite wire-api-tests
     , QuickCheck
     , ram
     , random
+    , regex-tdfa
     , schema-profunctor
     , servant
     , servant-server
@@ -772,6 +776,7 @@ test-suite wire-api-tests
     , tasty-hspec
     , tasty-hunit
     , tasty-quickcheck
+    , template-haskell
     , text
     , time
     , types-common           >=0.16
@@ -780,6 +785,7 @@ test-suite wire-api-tests
     , vector
     , wai
     , wire-api
+    , yaml
 
   ghc-options:
     -threaded -with-rtsopts=-N -Wunused-packages -Wno-x-partial
diff --git a/nix/wire-server.nix b/nix/wire-server.nix
index 47ff465720e..4d304f2f8c3 100644
--- a/nix/wire-server.nix
+++ b/nix/wire-server.nix
@@ -159,6 +159,22 @@ let
     inherit hlib mls-test-cli;
   });
 
+  # 'Test.Wire.API.Routes.OAuthScopes' checks the OAuth scopes nginz enforces
+  # against the ones the swagger docs advertise, so it needs
+  # charts/nginz/values.yaml at compile time.  Only a package's own directory is
+  # copied into the build sandbox, so splice the chart in -- and by making it
+  # part of 'src', changing the chart also invalidates the build.
+  wireApiWithNginzChart = hself: hsuper: {
+    wire-api = hlib.overrideCabal hsuper.wire-api (old: {
+      src = pkgs.runCommand "wire-api-src" { } ''
+        cp -r ${old.src} $out
+        chmod -R u+w $out
+        mkdir -p $out/test/unit/generated
+        cp ${../charts/nginz/values.yaml} $out/test/unit/generated/nginz-values.yaml
+      '';
+    });
+  };
+
   executables = hself: hsuper:
     attrsets.genAttrs (builtins.attrNames executablesMap) (e: withCleanedPath hsuper.${e});
 
@@ -173,6 +189,7 @@ let
     overrides = lib.composeManyExtensions [
       pinnedPackages
       (localPackages localMods)
+      wireApiWithNginzChart
       manualOverrides
       executables
       staticExecutables
diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs
index 6a399bf1635..80fa4d5fb64 100644
--- a/services/brig/src/Brig/API/Public.hs
+++ b/services/brig/src/Brig/API/Public.hs
@@ -55,7 +55,7 @@ import Brig.User.Client qualified as API
 import Cassandra qualified as C
 import Cassandra qualified as Data
 import Control.Error hiding (bool, note)
-import Control.Lens ((.~), (?~))
+import Control.Lens ((?~))
 import Control.Monad.Except
 import Data.Aeson
 import Data.ByteString (fromStrict)
@@ -111,7 +111,6 @@ import Wire.API.Federation.Error
 import Wire.API.Federation.Version qualified as Fed
 import Wire.API.Pagination
 import Wire.API.Properties qualified as Public
-import Wire.API.Routes.API
 import Wire.API.Routes.Bearer
 import Wire.API.Routes.Internal.Brig qualified as BrigInternalAPI
 import Wire.API.Routes.Internal.Cannon qualified as CannonInternalAPI
@@ -123,13 +122,7 @@ import Wire.API.Routes.MultiTablePaging qualified as Public
 import Wire.API.Routes.Named (Named (Named))
 import Wire.API.Routes.Public.Brig
 import Wire.API.Routes.Public.Brig.DomainVerification
-import Wire.API.Routes.Public.Brig.OAuth
-import Wire.API.Routes.Public.Cannon
-import Wire.API.Routes.Public.Cargohold
-import Wire.API.Routes.Public.Galley
-import Wire.API.Routes.Public.Gundeck
-import Wire.API.Routes.Public.Proxy
-import Wire.API.Routes.Public.Spar
+import Wire.API.Routes.Public.Swagger
 import Wire.API.Routes.Public.Util
 import Wire.API.Routes.Version
 import Wire.API.SwaggerHelper (cleanupSwagger)
@@ -245,20 +238,8 @@ internalEndpointsSwaggerDocsAPIs =
 versionedSwaggerDocsAPI :: Servant.Server VersionedSwaggerDocsAPI
 versionedSwaggerDocsAPI (Just (VersionNumber V17)) =
   swaggerSchemaUIServer $
-    ( serviceSwagger @VersionAPITag @'V17
-        <> serviceSwagger @BrigAPITag @'V17
-        <> serviceSwagger @GalleyAPITag @'V17
-        <> serviceSwagger @SparAPITag @'V17
-        <> serviceSwagger @CargoholdAPITag @'V17
-        <> serviceSwagger @CannonAPITag @'V17
-        <> serviceSwagger @GundeckAPITag @'V17
-        <> serviceSwagger @ProxyAPITag @'V17
-        <> serviceSwagger @OAuthAPITag @'V17
-    )
-      & S.info . S.title .~ "Wire-Server API"
+    devVersionSwagger
       & S.info . S.description ?~ $((unTypeCode . embedText) =<< makeRelativeToProject "docs/swagger.md")
-      & S.servers .~ [S.Server ("/" <> toUrlPiece V17) Nothing mempty]
-      & cleanupSwagger
 versionedSwaggerDocsAPI (Just (VersionNumber V16)) = swaggerPregenUIServer $(pregenSwagger V16)
 versionedSwaggerDocsAPI (Just (VersionNumber V15)) = swaggerPregenUIServer $(pregenSwagger V15)
 versionedSwaggerDocsAPI (Just (VersionNumber V14)) = swaggerPregenUIServer $(pregenSwagger V14)

From f2a9c1dfd08d8203f447605dfda180ff750da1bf Mon Sep 17 00:00:00 2001
From: Sven Tennie 
Date: Thu, 20 Aug 2026 15:59:21 +0200
Subject: [PATCH 099/113] Simplify `make psql` usage (#5465)

The password is already scattered around this repo, no need to not use
it here.
---
 Makefile | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/Makefile b/Makefile
index a9a0200f74b..ccce920f5fe 100644
--- a/Makefile
+++ b/Makefile
@@ -356,9 +356,7 @@ cqlsh:
 
 .PHONY: psql
 psql:
-	@grep -q wire-server:wire-server ~/.pgpass || \
-	  echo "consider running 'echo localhost:5432:$(PSQL_DB):wire-server:posty-the-gres > ~/.pgpass ; chmod 600 ~/.pgpass '"
-	psql -h localhost -p 5432 $(PSQL_DB) -U wire-server -w || \
+	PGPASSWORD=posty-the-gres psql -h localhost -p 5432 $(PSQL_DB) -U wire-server -w || \
 	  echo 'if the database is missing, consider running "make postgres-reset", or setting $$PSQL_DB to the correct table space.'
 
 .PHONY: db-reset-package

From 44a0db9204fa09b9765ee25ed9ebdba5016f4129 Mon Sep 17 00:00:00 2001
From: Sven Tennie 
Date: Thu, 20 Aug 2026 18:36:54 +0200
Subject: [PATCH 100/113] Add allowManualMigration flag to mlsMigration.config
 (#5456)

The `mlsMigration` team feature config now includes an `allowManualMigration`
boolean field (default `false`) that controls whether clients are permitted to
perform single-group (manual) MLS migrations. The field only steers client
behaviour (e.g. if a migration button is shown or not). It does not enforce
checks in the backend.
---
 ...28028-mlsmigration-allowManualMigration.md |   5 +
 charts/wire-server/values.yaml                |   2 +
 .../test/Test/FeatureFlags/MlsMigration.hs    | 104 +++++++++++++++++-
 integration/test/Test/FeatureFlags/Util.hs    |   3 +-
 libs/wire-api/src/Wire/API/Team/Feature.hs    |  20 +++-
 .../golden/Test/Wire/API/Golden/Manual.hs     |  10 ++
 .../API/Golden/Manual/MlsMigrationConfig.hs   |  54 +++++++++
 .../testObject_MlsMigrationConfig_1.json      |   9 ++
 .../testObject_MlsMigrationConfig_2.json      |   8 ++
 .../testObject_MlsMigrationConfig_3.json      |   8 ++
 libs/wire-api/wire-api.cabal                  |   1 +
 tools/db/migrate-features/src/Work.hs         |   1 +
 12 files changed, 217 insertions(+), 8 deletions(-)
 create mode 100644 changelog.d/1-api-changes/WPB-28028-mlsmigration-allowManualMigration.md
 create mode 100644 libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MlsMigrationConfig.hs
 create mode 100644 libs/wire-api/test/golden/testObject_MlsMigrationConfig_1.json
 create mode 100644 libs/wire-api/test/golden/testObject_MlsMigrationConfig_2.json
 create mode 100644 libs/wire-api/test/golden/testObject_MlsMigrationConfig_3.json

diff --git a/changelog.d/1-api-changes/WPB-28028-mlsmigration-allowManualMigration.md b/changelog.d/1-api-changes/WPB-28028-mlsmigration-allowManualMigration.md
new file mode 100644
index 00000000000..7a745fdf82d
--- /dev/null
+++ b/changelog.d/1-api-changes/WPB-28028-mlsmigration-allowManualMigration.md
@@ -0,0 +1,5 @@
+The `mlsMigration` team feature config now includes an `allowManualMigration`
+boolean field (default `false`) that controls whether clients are permitted to
+perform single-group (manual) MLS migrations. The field only steers client
+behaviour (e.g. if a migration button is shown or not). It does not enforce
+checks in the backend.
diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml
index 494b3a814bf..8bea454951f 100644
--- a/charts/wire-server/values.yaml
+++ b/charts/wire-server/values.yaml
@@ -242,6 +242,8 @@ galley:
               finaliseRegardlessAfter: null # "2029-10-17T00:00:00.000Z"
               usersThreshold: 100
               clientsThreshold: 100
+              # Allow group-wise migration by clients
+              allowManualMigration: false
             lockStatus: locked
         limitedEventFanout:
           defaults:
diff --git a/integration/test/Test/FeatureFlags/MlsMigration.hs b/integration/test/Test/FeatureFlags/MlsMigration.hs
index fefd5b70068..a60cd339675 100644
--- a/integration/test/Test/FeatureFlags/MlsMigration.hs
+++ b/integration/test/Test/FeatureFlags/MlsMigration.hs
@@ -49,6 +49,101 @@ testMlsMigrationDefaults = do
       feat <- Internal.getTeamFeature owner tid "mlsMigration" >>= getJSON 200
       feat %. "config" `shouldMatch` mlsMigrationDefaultConfig
 
+testMlsMigrationAllowManualMigration :: (HasCallStack) => App ()
+testMlsMigrationAllowManualMigration = do
+  (owner, tid, _) <- createTeam OwnDomain 0
+  void $ Public.setTeamFeatureConfig owner tid "mls" mlsEnable >>= getJSON 200
+
+  getResp0 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200
+  (getResp0 %. "config" %. "allowManualMigration") `shouldMatch` False
+
+  patchResp0 <-
+    Internal.patchTeamFeature owner tid "mlsMigration" (object ["lockStatus" .= "unlocked"])
+      >>= getJSON 200
+  (patchResp0 %. "config" %. "allowManualMigration") `shouldMatch` False
+
+  setResp1 <-
+    Public.setTeamFeatureConfig owner tid "mlsMigration" mlsMigrationConfig1
+      >>= getJSON 200
+  getResp1 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200
+  (getResp1 %. "config" %. "allowManualMigration") `shouldMatch` False
+  (getResp1 %. "config") `shouldMatch` (setResp1 %. "config")
+
+  setResp2 <-
+    Public.setTeamFeatureConfig owner tid "mlsMigration" mlsMigrationConfig2
+      >>= getJSON 200
+  getResp2 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200
+  (getResp2 %. "config" %. "allowManualMigration") `shouldMatch` True
+  (getResp2 %. "config") `shouldMatch` (setResp2 %. "config")
+
+  let patchWithoutField =
+        object
+          [ "status" .= "enabled",
+            "config"
+              .= object
+                [ "startTime" .= "2030-01-01T00:00:00Z"
+                ]
+          ]
+  setResp3 <-
+    Public.setTeamFeatureConfig owner tid "mlsMigration" patchWithoutField
+      >>= getJSON 200
+  getResp3 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200
+  (getResp3 %. "config" %. "allowManualMigration") `shouldMatch` False
+  (getResp3 %. "config") `shouldMatch` (setResp3 %. "config")
+
+  let patchWithField =
+        object
+          [ "status" .= "enabled",
+            "config"
+              .= object
+                [ "startTime" .= "2030-01-01T00:00:00Z",
+                  "allowManualMigration" .= True
+                ]
+          ]
+  setResp4 <-
+    Public.setTeamFeatureConfig owner tid "mlsMigration" patchWithField
+      >>= getJSON 200
+  getResp4 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200
+  (getResp4 %. "config" %. "allowManualMigration") `shouldMatch` True
+  (getResp4 %. "config") `shouldMatch` (setResp4 %. "config")
+
+-- | PUT replaces the whole config; it does not merge omitted fields with the
+-- previously stored value. This pins down that assumption so that future
+-- schema changes to individual fields (e.g. allowManualMigration) don't
+-- accidentally start relying on merge behaviour that doesn't exist.
+testMlsMigrationPutDoesNotMergeOmittedFields :: (HasCallStack) => App ()
+testMlsMigrationPutDoesNotMergeOmittedFields = do
+  (owner, tid, _) <- createTeam OwnDomain 0
+  void $ Public.setTeamFeatureConfig owner tid "mls" mlsEnable >>= getJSON 200
+  void
+    $ Internal.patchTeamFeature owner tid "mlsMigration" (object ["lockStatus" .= "unlocked"])
+    >>= getJSON 200
+
+  beforePatch <-
+    Public.setTeamFeatureConfig owner tid "mlsMigration" mlsMigrationConfig2
+      >>= getJSON 200
+  (beforePatch %. "config" %. "finaliseRegardlessAfter") `shouldMatch` "2031-10-17T00:00:00Z"
+
+  let partialConfig =
+        object
+          [ "status" .= "enabled",
+            "config"
+              .= object
+                [ "startTime" .= "2030-01-01T00:00:00Z"
+                ]
+          ]
+  void
+    $ Public.setTeamFeatureConfig owner tid "mlsMigration" partialConfig
+    >>= getJSON 200
+  afterPatch <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200
+
+  -- omitted fields are dropped, not carried over from mlsMigrationConfig2
+  assertFieldMissing afterPatch "config.finaliseRegardlessAfter"
+
+  -- allowManualMigration is always rendered (never omitted); reset to its
+  -- default of False, not merged from mlsMigrationConfig2's True
+  (afterPatch %. "config" %. "allowManualMigration") `shouldMatch` False
+
 mlsEnableConfig :: Value
 mlsEnableConfig =
   object
@@ -70,7 +165,8 @@ mlsMigrationDefaultConfig :: Value
 mlsMigrationDefaultConfig =
   object
     [ "startTime" .= "2029-05-16T10:11:12.123Z",
-      "finaliseRegardlessAfter" .= "2029-10-17T00:00:00Z"
+      "finaliseRegardlessAfter" .= "2029-10-17T00:00:00Z",
+      "allowManualMigration" .= False
     ]
 
 mlsMigrationConfig1 :: Value
@@ -80,7 +176,8 @@ mlsMigrationConfig1 =
       "config"
         .= object
           [ "startTime" .= "2029-05-16T10:11:12.123Z",
-            "finaliseRegardlessAfter" .= "2030-10-17T00:00:00Z"
+            "finaliseRegardlessAfter" .= "2030-10-17T00:00:00Z",
+            "allowManualMigration" .= False
           ]
     ]
 
@@ -91,6 +188,7 @@ mlsMigrationConfig2 =
       "config"
         .= object
           [ "startTime" .= "2030-05-16T10:11:12.123Z",
-            "finaliseRegardlessAfter" .= "2031-10-17T00:00:00Z"
+            "finaliseRegardlessAfter" .= "2031-10-17T00:00:00Z",
+            "allowManualMigration" .= True
           ]
     ]
diff --git a/integration/test/Test/FeatureFlags/Util.hs b/integration/test/Test/FeatureFlags/Util.hs
index 58d7da16cff..17a994de737 100644
--- a/integration/test/Test/FeatureFlags/Util.hs
+++ b/integration/test/Test/FeatureFlags/Util.hs
@@ -183,7 +183,8 @@ defAllFeatures =
             "config"
               .= object
                 [ "startTime" .= "2029-05-16T10:11:12.123Z",
-                  "finaliseRegardlessAfter" .= "2029-10-17T00:00:00Z"
+                  "finaliseRegardlessAfter" .= "2029-10-17T00:00:00Z",
+                  "allowManualMigration" .= False
                 ]
           ],
       "enforceFileDownloadLocation"
diff --git a/libs/wire-api/src/Wire/API/Team/Feature.hs b/libs/wire-api/src/Wire/API/Team/Feature.hs
index c9f8eef1b09..2e0d6ee8971 100644
--- a/libs/wire-api/src/Wire/API/Team/Feature.hs
+++ b/libs/wire-api/src/Wire/API/Team/Feature.hs
@@ -1625,7 +1625,16 @@ instance IsFeatureConfig MlsE2EIdConfig where
 
 data MlsMigrationConfigB t f = MlsMigrationConfig
   { startTime :: Wear t f (Maybe UTCTime),
-    finaliseRegardlessAfter :: Wear t f (Maybe UTCTime)
+    finaliseRegardlessAfter :: Wear t f (Maybe UTCTime),
+    -- | Allow users to manually trigger migrations from Proteus to MLS for
+    -- group conversations.
+    --
+    -- There is no logic behind this flag in the backend. It is solely meant for
+    -- clients to decide if they should show a button (to facilitate MLS
+    -- migration for a group conversation) or not.
+    --
+    -- The default is `False`.
+    allowManualMigration :: Wear t f Bool
   }
   deriving (BareB, Generic)
 
@@ -1648,24 +1657,27 @@ deriving via (BarbieFeature MlsMigrationConfigB) instance (ToSchema MlsMigration
 deriving via (RenderableTypeName MlsMigrationConfig) instance (RenderableSymbol MlsMigrationConfig)
 
 instance Default MlsMigrationConfig where
-  def = MlsMigrationConfig Nothing Nothing
+  def = MlsMigrationConfig Nothing Nothing False
 
 instance Arbitrary MlsMigrationConfig where
   arbitrary = do
     startTime <- fmap fromUTCTimeMillis <$> arbitrary
     finaliseRegardlessAfter <- fmap fromUTCTimeMillis <$> arbitrary
+    allowManualMigration <- arbitrary
     pure
       MlsMigrationConfig
         { startTime = startTime,
-          finaliseRegardlessAfter = finaliseRegardlessAfter
+          finaliseRegardlessAfter = finaliseRegardlessAfter,
+          allowManualMigration = allowManualMigration
         }
 
-instance (Typeable f, NestedMaybe f) => ToSchema (MlsMigrationConfigB Covered f) where
+instance (Typeable f, NestedMaybe f, OptWithDefault f) => ToSchema (MlsMigrationConfigB Covered f) where
   schema =
     object $
       MlsMigrationConfig
         <$> startTime .= nestedMaybeField "startTime" (unnamed utcTimeSchema)
         <*> finaliseRegardlessAfter .= nestedMaybeField "finaliseRegardlessAfter" (unnamed utcTimeSchema)
+        <*> allowManualMigration .= fromOpt (optField "allowManualMigration" (schema @Bool))
 
 instance Default (LockableFeature MlsMigrationConfig) where
   def = defLockedFeature
diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs
index 37d8a25650c..8d79db07860 100644
--- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs
+++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs
@@ -52,6 +52,7 @@ import Test.Wire.API.Golden.Manual.Login_user
 import Test.Wire.API.Golden.Manual.MLSKeys
 import Test.Wire.API.Golden.Manual.Meeting
 import Test.Wire.API.Golden.Manual.MeetingEvent
+import Test.Wire.API.Golden.Manual.MlsMigrationConfig
 import Test.Wire.API.Golden.Manual.Pagination
 import Test.Wire.API.Golden.Manual.Presence
 import Test.Wire.API.Golden.Manual.Push
@@ -350,6 +351,15 @@ tests =
         testObjects
           [ (testObject_MLSKeysByPurpose1, "testObject_MLSKeysByPurpose_1.json")
           ],
+      testGroup "Feature MlsMigrationConfig" $
+        testObjects
+          [(testObject_MlsMigrationConfig_1, "testObject_MlsMigrationConfig_1.json")],
+      testGroup "LockableFeature MlsMigrationConfig" $
+        testObjects
+          [(testObject_MlsMigrationConfig_2, "testObject_MlsMigrationConfig_2.json")],
+      testGroup "LockableFeaturePatch MlsMigrationConfig" $
+        testObjects
+          [(testObject_MlsMigrationConfig_3, "testObject_MlsMigrationConfig_3.json")],
       testGroup "SendActivationCode" $
         testObjects
           [ (testObject_SendActivationCode_1, "testObject_SendActivationCode_1.json"),
diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MlsMigrationConfig.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MlsMigrationConfig.hs
new file mode 100644
index 00000000000..793baa173c9
--- /dev/null
+++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MlsMigrationConfig.hs
@@ -0,0 +1,54 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Test.Wire.API.Golden.Manual.MlsMigrationConfig where
+
+import Data.Time
+import Imports
+import Wire.API.Team.Feature
+
+testObject_MlsMigrationConfig_1 :: Feature MlsMigrationConfig
+testObject_MlsMigrationConfig_1 =
+  Feature
+    FeatureStatusEnabled
+    ( MlsMigrationConfig
+        (Just (UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}))
+        (Just (UTCTime {utctDay = ModifiedJulianDay 58200, utctDayTime = 0}))
+        True
+    )
+
+testObject_MlsMigrationConfig_2 :: LockableFeature MlsMigrationConfig
+testObject_MlsMigrationConfig_2 =
+  LockableFeature
+    { status = FeatureStatusEnabled,
+      lockStatus = LockStatusUnlocked,
+      config = MlsMigrationConfig Nothing Nothing False
+    }
+
+testObject_MlsMigrationConfig_3 :: LockableFeaturePatch MlsMigrationConfig
+testObject_MlsMigrationConfig_3 =
+  LockableFeaturePatch
+    { status = Just FeatureStatusEnabled,
+      lockStatus = Nothing,
+      config =
+        Just
+          ( MlsMigrationConfig
+              Nothing
+              (Just (UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}))
+              False
+          )
+    }
diff --git a/libs/wire-api/test/golden/testObject_MlsMigrationConfig_1.json b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_1.json
new file mode 100644
index 00000000000..a83c41d54a5
--- /dev/null
+++ b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_1.json
@@ -0,0 +1,9 @@
+{
+    "config": {
+        "allowManualMigration": true,
+        "finaliseRegardlessAfter": "2018-03-23T00:00:00Z",
+        "startTime": "2018-01-01T00:00:00Z"
+    },
+    "status": "enabled",
+    "ttl": "unlimited"
+}
diff --git a/libs/wire-api/test/golden/testObject_MlsMigrationConfig_2.json b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_2.json
new file mode 100644
index 00000000000..6014232db55
--- /dev/null
+++ b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_2.json
@@ -0,0 +1,8 @@
+{
+    "config": {
+        "allowManualMigration": false
+    },
+    "lockStatus": "unlocked",
+    "status": "enabled",
+    "ttl": "unlimited"
+}
diff --git a/libs/wire-api/test/golden/testObject_MlsMigrationConfig_3.json b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_3.json
new file mode 100644
index 00000000000..eba2ce56578
--- /dev/null
+++ b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_3.json
@@ -0,0 +1,8 @@
+{
+    "config": {
+        "allowManualMigration": false,
+        "finaliseRegardlessAfter": "2018-01-01T00:00:00Z"
+    },
+    "status": "enabled",
+    "ttl": "unlimited"
+}
diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal
index d6d83c2fd4b..e7f4e0886e0 100644
--- a/libs/wire-api/wire-api.cabal
+++ b/libs/wire-api/wire-api.cabal
@@ -652,6 +652,7 @@ test-suite wire-api-golden-tests
     Test.Wire.API.Golden.Manual.Meeting
     Test.Wire.API.Golden.Manual.MeetingEvent
     Test.Wire.API.Golden.Manual.MLSKeys
+    Test.Wire.API.Golden.Manual.MlsMigrationConfig
     Test.Wire.API.Golden.Manual.Pagination
     Test.Wire.API.Golden.Manual.Presence
     Test.Wire.API.Golden.Manual.Push
diff --git a/tools/db/migrate-features/src/Work.hs b/tools/db/migrate-features/src/Work.hs
index 8aef63d20dd..60a1f8061d4 100644
--- a/tools/db/migrate-features/src/Work.hs
+++ b/tools/db/migrate-features/src/Work.hs
@@ -337,6 +337,7 @@ writeFeatures
                         ( MlsMigrationConfig @Covered
                             (fmap unOptionalUTCTime mls_migration_start_time)
                             (fmap unOptionalUTCTime mls_migration_finalise_regardless_after)
+                            def -- allowManualMigration was added recently, so there's nothing to migrate
                         )
                   }
 

From fd6c9bfed51fad946ca67a7f570b6c9b28c74bf1 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Thu, 20 Aug 2026 21:54:16 +0200
Subject: [PATCH 101/113] WPB-23434: Support SCIM PATCH of multi-valued emails
 attribute (#5419)

---
 cassandra-schema.cql                          |   2 +
 .../WPB-23434-scim-emails-now-echo-type       |   7 +
 changelog.d/3-bug-fixes/WPB-23434             |   4 +
 changelog.d/3-bug-fixes/WPB-23434-email-type  |  13 +
 .../3-bug-fixes/WPB-23434-multi-primary       |   4 +
 .../scim-email-subattr-remove-null            |  13 +
 .../5-internal/WPB-23434-scim-user-meta-store |   1 +
 integration/test/API/Spar.hs                  |  14 +
 integration/test/SetupHelpers.hs              |  25 +-
 integration/test/Test/Spar.hs                 | 265 +++++++++++++++-
 libs/hscim/src/Web/Scim/Filter.hs             |   5 +-
 libs/hscim/src/Web/Scim/Schema/PatchOp.hs     |   9 +-
 libs/hscim/src/Web/Scim/Schema/User.hs        | 290 +++++++++++++++++-
 libs/hscim/src/Web/Scim/Schema/User/Email.hs  |  32 +-
 libs/hscim/test/Test/Class/UserSpec.hs        |  27 ++
 libs/hscim/test/Test/Schema/PatchOpSpec.hs    |  10 +
 libs/hscim/test/Test/Schema/UserSpec.hs       | 200 +++++++++++-
 libs/wire-api/src/Wire/API/User/Scim.hs       |  10 +-
 ...UserTimesStore.hs => ScimUserMetaStore.hs} |  32 +-
 .../src/Wire/ScimUserMetaStore/Cassandra.hs   | 102 ++++++
 .../Mem.hs                                    |  27 +-
 .../src/Wire/ScimUserTimesStore/Cassandra.hs  |  87 ------
 libs/wire-subsystems/wire-subsystems.cabal    |   6 +-
 services/spar/spar.cabal                      |   1 +
 services/spar/src/Spar/API.hs                 |  12 +-
 .../spar/src/Spar/CanonicalInterpreter.hs     |   8 +-
 services/spar/src/Spar/Schema/Run.hs          |   4 +-
 services/spar/src/Spar/Schema/V23.hs          |  32 ++
 services/spar/src/Spar/Scim.hs                |   4 +-
 services/spar/src/Spar/Scim/User.hs           | 138 ++++++---
 .../Test/Spar/Scim/UserSpec.hs                |   9 +-
 services/spar/test/Test/Spar/Scim/UserSpec.hs |  10 +-
 32 files changed, 1191 insertions(+), 212 deletions(-)
 create mode 100644 changelog.d/1-api-changes/WPB-23434-scim-emails-now-echo-type
 create mode 100644 changelog.d/3-bug-fixes/WPB-23434
 create mode 100644 changelog.d/3-bug-fixes/WPB-23434-email-type
 create mode 100644 changelog.d/3-bug-fixes/WPB-23434-multi-primary
 create mode 100644 changelog.d/3-bug-fixes/scim-email-subattr-remove-null
 create mode 100644 changelog.d/5-internal/WPB-23434-scim-user-meta-store
 rename libs/wire-subsystems/src/Wire/{ScimUserTimesStore.hs => ScimUserMetaStore.hs} (50%)
 create mode 100644 libs/wire-subsystems/src/Wire/ScimUserMetaStore/Cassandra.hs
 rename libs/wire-subsystems/src/Wire/{ScimUserTimesStore => ScimUserMetaStore}/Mem.hs (65%)
 delete mode 100644 libs/wire-subsystems/src/Wire/ScimUserTimesStore/Cassandra.hs
 create mode 100644 services/spar/src/Spar/Schema/V23.hs

diff --git a/cassandra-schema.cql b/cassandra-schema.cql
index c79723f599c..9e5e1528992 100644
--- a/cassandra-schema.cql
+++ b/cassandra-schema.cql
@@ -2474,6 +2474,8 @@ CREATE TABLE spar_test.scim_external (
 CREATE TABLE spar_test.scim_user_times (
     uid uuid PRIMARY KEY,
     created_at timestamp,
+    email_primary boolean,
+    email_type text,
     last_updated_at timestamp
 ) WITH additional_write_policy = '99p'
     AND bloom_filter_fp_chance = 0.1
diff --git a/changelog.d/1-api-changes/WPB-23434-scim-emails-now-echo-type b/changelog.d/1-api-changes/WPB-23434-scim-emails-now-echo-type
new file mode 100644
index 00000000000..9d046cea961
--- /dev/null
+++ b/changelog.d/1-api-changes/WPB-23434-scim-emails-now-echo-type
@@ -0,0 +1,7 @@
+SCIM user resources returned by spar now include `type` on stored email
+addresses. Previously spar persisted no `type`, so SCIM PATCH operations with a
+value-path filter like `emails[type eq "work"].value` (as sent by Microsoft
+Entra ID) never matched the stored entry and silently appended a duplicate
+email instead of updating the address in place. Clients that compare full SCIM
+user payloads rather than individual fields (e.g. strict equality on the
+`emails` array) will see the additional `type` member. (WPB-23434)
diff --git a/changelog.d/3-bug-fixes/WPB-23434 b/changelog.d/3-bug-fixes/WPB-23434
new file mode 100644
index 00000000000..626855dbce1
--- /dev/null
+++ b/changelog.d/3-bug-fixes/WPB-23434
@@ -0,0 +1,4 @@
+SCIM PATCH now supports the `emails` multi-valued attribute (e.g. Entra's
+`emails[type eq "work"].value`), so user emails can be updated via SCIM. Identity
+providers that previously hit a `can not lens into multi-valued attributes yet`
+error when provisioning emails now succeed.
diff --git a/changelog.d/3-bug-fixes/WPB-23434-email-type b/changelog.d/3-bug-fixes/WPB-23434-email-type
new file mode 100644
index 00000000000..395787c37c1
--- /dev/null
+++ b/changelog.d/3-bug-fixes/WPB-23434-email-type
@@ -0,0 +1,13 @@
+SCIM email metadata is now persisted and echoed verbatim. spar stores the
+`type` and `primary` sub-attributes of the SCIM email entry it keeps (in
+`spar.scim_user_times`) and echoes them back on GET/POST/PATCH exactly as the
+IdP sent them, instead of synthesizing a hardcoded `type` of `"work"` (email
+`type` values are limited to 64 characters). As a result, PATCH value-path
+filters like Entra's `emails[type eq "work"].value` and Okta's
+`emails[primary eq true].value` match the stored entry for an in-place update
+when (and only when) the IdP actually supplied that metadata at provisioning
+time; users provisioned without it echo neither field. Since per RFC 7644
+§3.5.2 an `Add` on a non-existing target creates it, a type-filter PATCH
+against a user whose stored email carries no such metadata appends a new
+entry (which the single-email reduction collapses back to the old address,
+i.e. no visible change), while a primary-filter PATCH is a complete no-op.
diff --git a/changelog.d/3-bug-fixes/WPB-23434-multi-primary b/changelog.d/3-bug-fixes/WPB-23434-multi-primary
new file mode 100644
index 00000000000..737c51773e1
--- /dev/null
+++ b/changelog.d/3-bug-fixes/WPB-23434-multi-primary
@@ -0,0 +1,4 @@
+SCIM user provisioning now rejects requests (HTTP 400) that mark more than one
+email as `primary`, an RFC 7643 §2.4 violation. Previously spar silently picked
+one primary and dropped the rest, masking client-side misconfiguration. Requests
+with zero or one primary email are unchanged.
diff --git a/changelog.d/3-bug-fixes/scim-email-subattr-remove-null b/changelog.d/3-bug-fixes/scim-email-subattr-remove-null
new file mode 100644
index 00000000000..88ef89e88fe
--- /dev/null
+++ b/changelog.d/3-bug-fixes/scim-email-subattr-remove-null
@@ -0,0 +1,13 @@
+SCIM PATCH on `emails` now handles the email `type` and `primary`
+sub-attributes per RFC 7644 §3.5.2.2 and RFC 7643 §2.5:
+`remove emails[type eq "work"].type` (or `.primary`) unassigns just that
+sub-attribute and keeps the entry (including the address and the other
+sub-attribute) instead of deleting the whole record; `replace`/`add` with an
+explicit `"value": null` unassigns the sub-attribute the same way; and a
+filterless `remove emails` clears all email entries. Removing or nulling the
+`.value` sub-attribute is rejected with a 400 pointing at whole-entry removal,
+since the address is the record's identity. As a consequence of preserving
+explicit `null` values in PATCH operations, `replace` with `null` on
+`displayName`/`externalId`/`active` now unassigns the attribute (RFC 7643 §2.5)
+like the corresponding `remove` already did, instead of failing with
+"No value was provided".
diff --git a/changelog.d/5-internal/WPB-23434-scim-user-meta-store b/changelog.d/5-internal/WPB-23434-scim-user-meta-store
new file mode 100644
index 00000000000..bef5b88d891
--- /dev/null
+++ b/changelog.d/5-internal/WPB-23434-scim-user-meta-store
@@ -0,0 +1 @@
+Rename `Wire.ScimUserTimesStore` to `Wire.ScimUserMetaStore` (`ScimUserTimes` -> `ScimUserMeta`); the store also holds SCIM email metadata now. The Cassandra table `spar.scim_user_times` is unchanged.
diff --git a/integration/test/API/Spar.hs b/integration/test/API/Spar.hs
index b679e437256..003ca6afb49 100644
--- a/integration/test/API/Spar.hs
+++ b/integration/test/API/Spar.hs
@@ -112,6 +112,20 @@ updateScimUser domain scimToken userId scimUser = do
     & scimCommonHeaders scimToken
     & addJSON body
 
+patchScimUser ::
+  (HasCallStack, MakesValue domain, MakesValue patchOp) =>
+  domain ->
+  String ->
+  String ->
+  patchOp ->
+  App Response
+patchScimUser domain scimToken userId patchOp = do
+  req <- baseRequest domain Spar Versioned $ joinHttpPath ["scim", "v2", "Users", userId]
+  body <- make patchOp
+  submit "PATCH" $ req
+    & scimCommonHeaders scimToken
+    & addJSON body
+
 createScimUserGroup :: (HasCallStack, MakesValue domain, MakesValue scimUserGroup) => domain -> String -> scimUserGroup -> App Response
 createScimUserGroup domain token scimUserGroup = do
   req <- baseRequest domain Spar Versioned "/scim/v2/Groups"
diff --git a/integration/test/SetupHelpers.hs b/integration/test/SetupHelpers.hs
index e36d57efb2b..f4ed0509567 100644
--- a/integration/test/SetupHelpers.hs
+++ b/integration/test/SetupHelpers.hs
@@ -414,18 +414,25 @@ randomScimUser :: App Value
 randomScimUser = randomScimUserWith def
 
 randomScimUserWithEmail :: String -> String -> App Value
-randomScimUserWithEmail extId email =
+randomScimUserWithEmail extId email = randomScimUserWithEmailAndMeta extId email Nothing Nothing
+
+randomScimUserWithEmailAndMeta :: String -> String -> Maybe String -> Maybe Bool -> App Value
+randomScimUserWithEmailAndMeta extId email ty pr =
   randomScimUserWith
     def
       { mkExternalId = pure extId,
         prependExternalIdToEmails = False,
-        mkOtherEmails = pure [email]
+        mkOtherEmails = pure [email],
+        mkEmailType = pure ty,
+        mkEmailPrimary = pure pr
       }
 
 data RandomScimUserParams = RandomScimUserParams
   { mkExternalId :: App String,
     prependExternalIdToEmails :: Bool, -- NB: this flag is also honored if externalId is not an email!
-    mkOtherEmails :: App [String]
+    mkOtherEmails :: App [String],
+    mkEmailType :: App (Maybe String), -- SCIM `type` attached to each email entry; emitted only when 'Just'.
+    mkEmailPrimary :: App (Maybe Bool) -- SCIM `primary` attached to each email entry; emitted only when 'Just'.
   }
 
 instance Default RandomScimUserParams where
@@ -433,14 +440,22 @@ instance Default RandomScimUserParams where
     RandomScimUserParams
       { mkExternalId = randomEmail,
         prependExternalIdToEmails = True,
-        mkOtherEmails = pure []
+        mkOtherEmails = pure [],
+        mkEmailType = pure Nothing,
+        mkEmailPrimary = pure Nothing
       }
 
 randomScimUserWith :: (HasCallStack) => RandomScimUserParams -> App Value
 randomScimUserWith params = do
   extId <- params.mkExternalId
+  ty <- params.mkEmailType
+  pr <- params.mkEmailPrimary
   emails <- do
-    let mk email = object ["value" .= email]
+    let mk email =
+          object $
+            ["value" .= email]
+              <> ["type" .= t | Just t <- [ty]]
+              <> ["primary" .= p | Just p <- [pr]]
         hd = [extId | params.prependExternalIdToEmails]
     tl <- params.mkOtherEmails
     pure $ Array (fromList (mk <$> (hd <> tl)))
diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs
index cd70ea77b27..7506c5f3f34 100644
--- a/integration/test/Test/Spar.hs
+++ b/integration/test/Test/Spar.hs
@@ -300,6 +300,226 @@ testSparExternalIdDifferentFromEmailWithIdp = do
       subject <- u %. "sso_id.subject" >>= asString
       subject `shouldContainString` currentExtId
 
+testSparPatchEmailValuePath :: (HasCallStack) => App ()
+testSparPatchEmailValuePath = do
+  (owner, tid, _) <- createTeam OwnDomain 1
+  void $ setTeamFeatureStatus owner tid "sso" "enabled"
+  void $ registerTestIdPWithMeta owner >>= getJSON 201
+  -- Disable SAML email validation so the provisioned email is activated
+  -- directly (the IdP vouches for it), with no separate activation step.
+  void $ setTeamFeatureStatus owner tid "validateSAMLemails" "disabled"
+  tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString
+  extId <- randomExternalId
+  -- Exercise create-on-absent: a SAML user provisioned with no email receives
+  -- its first work email via Entra's @Add@ on @emails[type eq "work"].value@.
+  -- This user has no email yet, so the value-path filter matches nothing and
+  -- the entry is created (with @type = "work"@ taken from the filter, which is
+  -- then persisted and echoed). In-place update of an existing typed email is
+  -- covered by 'testSparPatchEmailValuePathInPlace'.
+  scimUser <- randomScimUserWith def {mkExternalId = pure extId} >>= removeField "emails"
+  userId <- createScimUser OwnDomain tok scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+  newEmail <- randomEmail
+  let patchOp = scimAddPatchOp "emails[type eq \"work\"].value" newEmail
+  bindResponse (patchScimUser OwnDomain tok userId patchOp) $ \res -> do
+    res.status `shouldMatchInt` 200
+  -- The provisioned email propagates end-to-end: SCIM GET reflects it and,
+  -- with validation disabled, it is active in Brig.  'eventually' is needed
+  -- because the PATCH 200 only means spar accepted the change; provisioning
+  -- of the new email in Brig (auto-activation, since @validateSAMLemails@ is
+  -- disabled) happens asynchronously, and there is no user-facing event to
+  -- synchronize on for a SCIM-provisioned, not-yet-registered user.  This
+  -- mirrors the deprecated spar suite (specEmailValidation), which polls the
+  -- same way.
+  eventually $ do
+    checkSparGetUserAndFindByExtId OwnDomain tok extId userId $ \u -> do
+      storedEmail <- u %. "emails" >>= asList >>= assertOne
+      storedEmail %. "value" `shouldMatch` newEmail
+      storedEmail %. "type" `shouldMatch` ("work" :: String)
+    bindResponse (getUsersId OwnDomain [userId]) $ \res -> do
+      res.status `shouldMatchInt` 200
+      u <- res.json & asList >>= assertOne
+      u %. "email" `shouldMatch` newEmail
+
+testSparPatchEmailValuePathInPlace :: (HasCallStack) => App ()
+testSparPatchEmailValuePathInPlace = do
+  (owner, tid, _) <- createTeam OwnDomain 1
+  void $ setTeamFeatureStatus owner tid "sso" "enabled"
+  void $ registerTestIdPWithMeta owner >>= getJSON 201
+  -- Disable SAML email validation so the updated email is activated directly
+  -- (the IdP vouches for it), with no separate activation step.
+  void $ setTeamFeatureStatus owner tid "validateSAMLemails" "disabled"
+  tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString
+  extId <- randomExternalId
+  email <- randomEmail
+  -- Provision with an explicit, non-default @type@ so the test proves the
+  -- echoed type comes from what was stored, not a hardcode.
+  scimUser <- randomScimUserWithEmailAndMeta extId email (Just "home") Nothing
+  userId <- createScimUser OwnDomain tok scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+  newEmail <- randomEmail
+  let patchOp = scimAddPatchOp "emails[type eq \"home\"].value" newEmail
+  bindResponse (patchScimUser OwnDomain tok userId patchOp) $ \res -> do
+    res.status `shouldMatchInt` 200
+  -- In-place update (not create-on-absent): the value-path filter
+  -- @emails[type eq "home"]@ matches the stored entry (whose @type = "home"@
+  -- was persisted at provisioning time) and updates its @.value@. The proof
+  -- is the new value and the echoed @type = "home"@ below -- a
+  -- create-on-absent append would be collapsed back to the OLD address by
+  -- 'scimEmailsToEmailAddress', failing the value assertion.
+  --
+  -- 'eventually' for the same reason as in 'testSparPatchEmailValuePath'
+  -- above: the PATCH 200 precedes the asynchronous email re-provisioning in
+  -- Brig, and no synchronizing event exists for it.
+  eventually $ do
+    checkSparGetUserAndFindByExtId OwnDomain tok extId userId $ \u -> do
+      storedEmail <- u %. "emails" >>= asList >>= assertOne
+      storedEmail %. "value" `shouldMatch` newEmail
+      storedEmail %. "type" `shouldMatch` ("home" :: String)
+    bindResponse (getUsersId OwnDomain [userId]) $ \res -> do
+      res.status `shouldMatchInt` 200
+      u <- res.json & asList >>= assertOne
+      u %. "email" `shouldMatch` newEmail
+
+-- | SCIM email metadata (@type@, @primary@) round-trips verbatim: spar
+-- persists what the IdP sends and echoes exactly that; users provisioned
+-- without metadata get back an email object with only @value@ (strict echo).
+testSparScimEmailMetaRoundTrip :: (HasCallStack) => App ()
+testSparScimEmailMetaRoundTrip = do
+  (owner, tid, _) <- createTeam OwnDomain 1
+  void $ setTeamFeatureStatus owner tid "sso" "enabled"
+  void $ registerTestIdPWithMeta owner >>= getJSON 201
+  -- Disable SAML email validation so provisioned/updated emails are activated
+  -- directly, with no separate activation step.
+  void $ setTeamFeatureStatus owner tid "validateSAMLemails" "disabled"
+  tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString
+
+  -- Provision with explicit metadata; GET must echo it exactly.
+  extId <- randomExternalId
+  email <- randomEmail
+  scimUser <- randomScimUserWithEmailAndMeta extId email (Just "work") (Just True)
+  userId <- createScimUser OwnDomain tok scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+  let wantEmail = object ["value" .= email, "type" .= ("work" :: String), "primary" .= True]
+  getScimUser OwnDomain tok userId `bindResponse` \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [wantEmail]
+
+  -- Okta-style in-place update via a filter on `primary`; the new address
+  -- keeps the stored metadata.
+  newEmail <- randomEmail
+  let patchOp = scimAddPatchOp "emails[primary eq true].value" newEmail
+  bindResponse (patchScimUser OwnDomain tok userId patchOp) $ \res -> do
+    res.status `shouldMatchInt` 200
+  eventually $ do
+    getScimUser OwnDomain tok userId `bindResponse` \res -> do
+      res.status `shouldMatchInt` 200
+      res.json
+        %. "emails"
+        `shouldMatch` [object ["value" .= newEmail, "type" .= ("work" :: String), "primary" .= True]]
+    bindResponse (getUsersId OwnDomain [userId]) $ \res -> do
+      res.status `shouldMatchInt` 200
+      u <- res.json & asList >>= assertOne
+      u %. "email" `shouldMatch` newEmail
+
+  -- Strict echo: a user provisioned WITHOUT metadata gets back an email
+  -- object containing only @value@ -- no @type@/@primary@ keys are
+  -- synthesized.
+  extId2 <- randomExternalId
+  email2 <- randomEmail
+  scimUser2 <- randomScimUserWithEmail extId2 email2
+  userId2 <- createScimUser OwnDomain tok scimUser2 >>= getJSON 201 >>= (%. "id") >>= asString
+  getScimUser OwnDomain tok userId2 `bindResponse` \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [object ["value" .= email2]]
+
+-- | Non-"work" email types are accepted and echoed verbatim (no normalization
+-- to "work").
+testSparScimEmailTypeNonWorkEcho :: (HasCallStack) => App ()
+testSparScimEmailTypeNonWorkEcho = do
+  (owner, _tid, _) <- createTeam OwnDomain 1
+  tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString
+  mapM_
+    ( \ty -> do
+        extId <- randomExternalId
+        email <- randomEmail
+        scimUser <- randomScimUserWithEmailAndMeta extId email (Just ty) Nothing
+        userId <- createScimUser OwnDomain tok scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+        getScimUser OwnDomain tok userId `bindResponse` \res -> do
+          res.status `shouldMatchInt` 200
+          res.json %. "emails" `shouldMatch` [object ["value" .= email, "type" .= ty]]
+    )
+    ["home", "other"]
+
+-- | RFC 7644 §3.5.2.2: removing a sub-attribute unassigns it and keeps the
+-- record; RFC 7643 §2.5 (and RFC 7644 §3.5.2): assigning @null@ is equivalent
+-- to unassignment.  Removing or nulling @emails[type eq \"work\"].type@ must
+-- not erase the email value or the @primary@ flag.
+testSparPatchEmailSubAttrRemoveNull :: (HasCallStack) => App ()
+testSparPatchEmailSubAttrRemoveNull = do
+  (owner, _tid, _) <- createTeam OwnDomain 1
+  tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString
+
+  extId <- randomExternalId
+  email <- randomEmail
+  scimUser <- randomScimUserWithEmailAndMeta extId email (Just "work") (Just True)
+  userId <- createScimUser OwnDomain tok scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+  let typePath = "emails[type eq \"work\"].type"
+      wantFull = object ["value" .= email, "type" .= ("work" :: String), "primary" .= True]
+      wantKept = object ["value" .= email, "primary" .= True]
+  getScimUser OwnDomain tok userId `bindResponse` \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [wantFull]
+
+  -- PATCH Remove on the type sub-attribute: synchronous strict echo (the
+  -- store write precedes the re-read), type key absent.
+  bindResponse (patchScimUser OwnDomain tok userId (scimPatchOp "Remove" typePath Nothing)) $ \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [wantKept]
+  getScimUser OwnDomain tok userId `bindResponse` \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [wantKept]
+
+  -- Strict round-trip consequence: the value-path filter
+  -- @emails[type eq \"work\"]@ no longer matches the stored entry, so the
+  -- Add takes the create-on-absent path, appends a second entry, and the
+  -- reduction back to one email (primary, else first) keeps the OLD address.
+  newEmail <- randomEmail
+  bindResponse (patchScimUser OwnDomain tok userId (scimAddPatchOp "emails[type eq \"work\"].value" newEmail)) $ \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [wantKept]
+  getScimUser OwnDomain tok userId `bindResponse` \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [wantKept]
+
+  -- PATCH Replace with null is equivalent to Remove (RFC 7643 §2.5).  Fresh
+  -- user: a type-less entry no longer matches the filter above.
+  extId2 <- randomExternalId
+  email2 <- randomEmail
+  scimUser2 <- randomScimUserWithEmailAndMeta extId2 email2 (Just "work") (Just True)
+  userId2 <- createScimUser OwnDomain tok scimUser2 >>= getJSON 201 >>= (%. "id") >>= asString
+  bindResponse (patchScimUser OwnDomain tok userId2 (scimPatchOp "Replace" typePath (Just A.Null))) $ \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [object ["value" .= email2, "primary" .= True]]
+  getScimUser OwnDomain tok userId2 `bindResponse` \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [object ["value" .= email2, "primary" .= True]]
+
+testSparRejectsMultiplePrimaryEmails :: (HasCallStack) => App ()
+testSparRejectsMultiplePrimaryEmails = do
+  (owner, _tid, _) <- createTeam OwnDomain 1
+  tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString
+  email1 <- randomEmail
+  email2 <- randomEmail
+  scimUser <-
+    randomScimUserWith def
+      >>= setField
+        "emails"
+        ( toJSON
+            [ object ["value" .= email1, "primary" .= True],
+              object ["value" .= email2, "primary" .= True]
+            ]
+        )
+  bindResponse (createScimUser OwnDomain tok scimUser) $ \res ->
+    res.status `shouldMatchInt` 400
+
 testSparExternalIdDifferentFromEmail :: (HasCallStack) => App ()
 testSparExternalIdDifferentFromEmail = do
   (owner, tid, _) <- createTeam OwnDomain 1
@@ -412,6 +632,18 @@ testSparExternalIdUpdateToANonEmail = do
   updatedScimUser <- setField "externalId" extId scimUser
   updateScimUser OwnDomain tok userId updatedScimUser >>= assertStatus 400
 
+-- | A SCIM PatchOp with the given op, path, and optional value.
+scimPatchOp :: String -> String -> Maybe A.Value -> A.Value
+scimPatchOp op path value =
+  object
+    [ "schemas" .= ["urn:ietf:params:scim:api:messages:2.0:PatchOp" :: String],
+      "Operations"
+        .= [object (["op" .= op, "path" .= path] <> ["value" .= v | Just v <- [value]])]
+    ]
+
+scimAddPatchOp :: String -> String -> A.Value
+scimAddPatchOp path value = scimPatchOp "Add" path (Just (A.String . cs $ value))
+
 testSparMigrateFromExternalIdOnlyToEmail :: (HasCallStack) => Tagged "mailUnchanged" Bool -> App ()
 testSparMigrateFromExternalIdOnlyToEmail (MkTagged emailUnchanged) = do
   (owner, tid, _) <- createTeam OwnDomain 1
@@ -423,11 +655,11 @@ testSparMigrateFromExternalIdOnlyToEmail (MkTagged emailUnchanged) = do
 
   -- Verify that updating a user with an empty emails does not change the email
   bindResponse (updateScimUser OwnDomain tok userId scimUser) $ \resp -> do
-    resp.json %. "emails" `shouldMatch` (toJSON [object ["value" .= email]])
+    resp.json %. "emails" `shouldMatch` (toJSON [scimStoredEmail email])
     resp.status `shouldMatchInt` 200
 
   newEmail <- if emailUnchanged then pure email else randomEmail
-  let newEmails = (toJSON [object ["value" .= newEmail]])
+  let newEmails = toJSON [scimStoredEmail newEmail]
   updatedScimUser <- setField "emails" newEmails scimUser
   updateScimUser OwnDomain tok userId updatedScimUser `bindResponse` \resp -> do
     resp.status `shouldMatchInt` 200
@@ -459,6 +691,13 @@ checkSparGetUserAndFindByExtId domain tok extId uid k = do
 
   userByUid `shouldMatch` userByIdExtId
 
+-- | Expected SCIM email object. spar echoes the @type@/@primary@ metadata of the
+-- stored email entry exactly as provisioned (see
+-- 'Spar.Scim.User.synthesizeScimUser'); fixtures in this module send no
+-- metadata, so the expected email object has only @value@.
+scimStoredEmail :: String -> Value
+scimStoredEmail addr = object ["value" .= addr]
+
 testSparScimTokenLimit :: (HasCallStack) => App ()
 testSparScimTokenLimit = withModifiedBackend
   def
@@ -1144,7 +1383,7 @@ testScimUpdateEmailAddress (TaggedBool extIdIsEmail) (TaggedBool requireExternal
     res.json %. "id" `shouldMatch` uid
     lookupField res.json "emails"
       `shouldMatch` ( if extIdIsEmail
-                        then Just [object ["value" .= oldEmail]]
+                        then Just [scimStoredEmail oldEmail]
                         else Nothing
                     )
 
@@ -1163,11 +1402,11 @@ testScimUpdateEmailAddress (TaggedBool extIdIsEmail) (TaggedBool requireExternal
 
   updateScimUser OwnDomain tok uid newScimUser `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail]
 
   getScimUser OwnDomain tok uid `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail]
 
   when requireExternalEmailVerification $ do
     getUsersId OwnDomain [uid] `bindResponse` \res -> do
@@ -1231,7 +1470,7 @@ testScimUpdateEmailAddressAndExternalId = do
   getScimUser OwnDomain tok brigUserId `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
     res.json %. "id" `shouldMatch` brigUserId
-    res.json %. "emails" `shouldMatch` [object ["value" .= extId1]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail extId1]
 
   findUsersByExternalId OwnDomain tok extId1 `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
@@ -1254,11 +1493,11 @@ testScimUpdateEmailAddressAndExternalId = do
   updateScimUser OwnDomain tok brigUserId newScimUser1 `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
     res.json %. "externalId" `shouldMatch` extId1
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail1]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail1]
 
   getScimUser OwnDomain tok brigUserId `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail1]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail1]
 
   findUsersByExternalId OwnDomain tok extId1 `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
@@ -1287,11 +1526,11 @@ testScimUpdateEmailAddressAndExternalId = do
   updateScimUser OwnDomain tok brigUserId newScimUser2 `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
     res.json %. "externalId" `shouldMatch` newExtId2
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail1]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail1]
 
   getScimUser OwnDomain tok brigUserId `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail1]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail1]
 
   findUsersByExternalId OwnDomain tok newExtId2 `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
@@ -1320,11 +1559,11 @@ testScimUpdateEmailAddressAndExternalId = do
   updateScimUser OwnDomain tok brigUserId newScimUser3 `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
     res.json %. "externalId" `shouldMatch` newEmail3
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail1]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail1]
 
   getScimUser OwnDomain tok brigUserId `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail1]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail1]
 
   findUsersByExternalId OwnDomain tok newEmail3 `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
@@ -1536,7 +1775,7 @@ testAllowUpdatesBySCIMWhenE2EIdEnabled (TaggedBool ssoEnabled) = do
       su <- setField "emails" [object ["value" .= newEmail]] scimUser
       bindResponse (updateScimUser OwnDomain tok uid su) $ \res -> do
         res.status `shouldMatchInt` 200
-        res.json %. "emails" `shouldMatch` [object ["value" .= newEmail]]
+        res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail]
       activateEmail OwnDomain newEmail
       bindResponse (getUsersId OwnDomain [uid]) $ \res -> do
         res.status `shouldMatchInt` 200
diff --git a/libs/hscim/src/Web/Scim/Filter.hs b/libs/hscim/src/Web/Scim/Filter.hs
index 5862f6a36bf..e9947556689 100644
--- a/libs/hscim/src/Web/Scim/Filter.hs
+++ b/libs/hscim/src/Web/Scim/Filter.hs
@@ -128,7 +128,10 @@ data Filter
 -- TODO(arianvp): This is a slight simplification at the moment as we
 -- don't support the complete Filter grammar. This should be a
 -- valFilter, not a FILTER.
-data ValuePath = ValuePath AttrPath Filter
+data ValuePath = ValuePath
+  { valuePathAttrPath :: AttrPath,
+    valuePathFilter :: Filter
+  }
   deriving (Eq, Show)
 
 -- | subAttr   = "." ATTRNAME
diff --git a/libs/hscim/src/Web/Scim/Schema/PatchOp.hs b/libs/hscim/src/Web/Scim/Schema/PatchOp.hs
index 1ac01c3b166..63c96f38870 100644
--- a/libs/hscim/src/Web/Scim/Schema/PatchOp.hs
+++ b/libs/hscim/src/Web/Scim/Schema/PatchOp.hs
@@ -22,7 +22,7 @@ import Control.Monad (guard)
 import Control.Monad.Except
 import qualified Data.Aeson.Key as Key
 import qualified Data.Aeson.KeyMap as KeyMap
-import Data.Aeson.Types (FromJSON (parseJSON), ToJSON (toJSON), Value (String), object, withObject, withText, (.:), (.:?), (.=))
+import Data.Aeson.Types (FromJSON (parseJSON), ToJSON (toJSON), Value (String), object, withObject, withText, (.:), (.=))
 import qualified Data.Aeson.Types as Aeson
 import Data.Attoparsec.ByteString (Parser, endOfInput, parseOnly)
 import Data.Bifunctor (first)
@@ -107,7 +107,12 @@ operationFromJSON schemas' =
     Operation
       <$> (o .: "op")
       <*> Aeson.explicitParseFieldMaybe (pathFromJSON schemas') o "path"
-      <*> (o .:? "value")
+      -- RFC 7643 §2.5 (Unassigned and Null Values): "Unassigned attributes,
+      -- the null value, or an empty array [...] SHALL be considered to be
+      -- equivalent in 'state'." Keep an explicit `"value": null` (Just Null)
+      -- distinct from an absent `value` member (Nothing) so consumers can
+      -- implement null-unassignment.
+      <*> pure (KeyMap.lookup "value" o)
 
 pathFromJSON :: [Schema] -> Value -> Aeson.Parser Path
 pathFromJSON schemas' =
diff --git a/libs/hscim/src/Web/Scim/Schema/User.hs b/libs/hscim/src/Web/Scim/Schema/User.hs
index c2f32deefff..89dc345a10a 100644
--- a/libs/hscim/src/Web/Scim/Schema/User.hs
+++ b/libs/hscim/src/Web/Scim/Schema/User.hs
@@ -54,6 +54,13 @@
 --  and all the others are either implied 'primary: false' or must be checked
 --  that they're false
 --
+--  (Partially addressed for @emails@: at most one @primary: true@ entry is
+--  now enforced at selection time in
+--  "Web.Scim.Schema.User.Email".'Web.Scim.Schema.User.Email.scimEmailsToEmail'
+--  (and 'Web.Scim.Schema.User.Email.scimEmailsToEmailAddress'), which
+--  rejects a multi-primary @emails@ list instead of silently picking
+--  one. Other multi-valued attributes are not yet validated.)
+--
 --
 -- == Attribute names
 --
@@ -77,19 +84,30 @@ import Data.Aeson
 import qualified Data.Aeson.Key as Key
 import qualified Data.Aeson.KeyMap as KeyMap
 import Data.List ((\\))
+import Data.Maybe (fromMaybe)
 import Data.Text (Text, pack)
 import qualified Data.Text as Text
+import Data.Text.Encoding (decodeUtf8)
 import GHC.Generics (Generic)
 import Lens.Micro
+import qualified Text.Email.Validate as EmailValidate
 import Web.Scim.AttrName
-import Web.Scim.Filter (AttrPath (..))
+import Web.Scim.Filter
+  ( AttrPath (..),
+    CompValue (..),
+    CompareOp (..),
+    Filter (..),
+    SubAttr (..),
+    ValuePath (..),
+    compareStr,
+  )
 import Web.Scim.Schema.Common
 import Web.Scim.Schema.Error
 import Web.Scim.Schema.PatchOp
 import Web.Scim.Schema.Schema (Schema (..), getSchemaUri)
 import Web.Scim.Schema.User.Address (Address)
 import Web.Scim.Schema.User.Certificate (Certificate)
-import Web.Scim.Schema.User.Email (Email)
+import Web.Scim.Schema.User.Email (Email (Email, primary, typ), EmailAddress (..))
 import Web.Scim.Schema.User.Entitlement (Entitlement)
 import Web.Scim.Schema.User.IM (IM)
 import Web.Scim.Schema.User.Name (Name)
@@ -307,6 +325,24 @@ applyUserOperation ::
   User tag ->
   Operation ->
   m (User tag)
+applyUserOperation user (Operation Add (Just (IntoValuePath vp mSub)) (Just val)) =
+  case vp of
+    ValuePath (AttrPath _ attr _) _
+      | attr == "emails" -> addEmailsValuePath user vp mSub val
+      | otherwise ->
+          throwError
+            ( badRequest
+                InvalidPath
+                (Just "multi-valued PATCH is only supported for 'emails'")
+            )
+-- Catch-all: for single-valued 'NormalPath' attributes (username, displayname,
+-- externalid, active) an @Add@ coincides with a @Replace@ (RFC 7644 §3.5.2.1:
+-- a single-valued target has its value replaced). @roles@ is multi-valued
+-- (@[Text]@), so the rewrite turns an RFC-mandated append into an overwrite --
+-- a known deviation, acceptable while no client relies on append semantics for
+-- @roles@. Multi-valued value-path @Add@ is intercepted above; any future
+-- complex or multi-valued attribute added to 'NormalPath' must not rely on
+-- this rewrite.
 applyUserOperation user (Operation Add path value) = applyUserOperation user (Operation Replace path value)
 applyUserOperation user (Operation Replace (Just (NormalPath (AttrPath _schema attr _subAttr))) (Just value)) =
   case attr of
@@ -321,8 +357,16 @@ applyUserOperation user (Operation Replace (Just (NormalPath (AttrPath _schema a
     "roles" ->
       (\x -> user {roles = x}) <$> resultToScimError (fromJSON value)
     _ -> throwError (badRequest InvalidPath (Just "we only support attributes username, displayname, externalid, active, roles"))
-applyUserOperation _ (Operation Replace (Just (IntoValuePath _ _)) _) = do
-  throwError (badRequest InvalidPath (Just "can not lens into multi-valued attributes yet"))
+applyUserOperation user (Operation Replace (Just (IntoValuePath vp mSub)) (Just val)) =
+  case vp of
+    ValuePath (AttrPath _ attr _) _
+      | attr == "emails" -> replaceEmailsValuePath user vp mSub val
+      | otherwise ->
+          throwError
+            ( badRequest
+                InvalidPath
+                (Just "multi-valued PATCH is only supported for 'emails'")
+            )
 applyUserOperation user (Operation Replace Nothing (Just value)) = do
   case value of
     Object hm | null ((AttrName . Key.toText <$> KeyMap.keys hm) \\ ["username", "displayname", "externalid", "active", "roles"]) -> do
@@ -338,16 +382,248 @@ applyUserOperation user (Operation Replace Nothing (Just value)) = do
 applyUserOperation _ (Operation Replace _ Nothing) =
   throwError (badRequest InvalidValue (Just "No value was provided"))
 applyUserOperation _ (Operation Remove Nothing _) = throwError (badRequest NoTarget Nothing)
-applyUserOperation user (Operation Remove (Just (NormalPath (AttrPath _schema attr _subAttr))) _value) =
+-- RFC 7644 §3.5.2.2 (Remove Operation): "If the target location is a
+-- single-value attribute, the attribute and its associated value is removed,
+-- and the attribute SHALL be considered unassigned." and: "If the target
+-- location is a multi-valued attribute and no filter is specified, the
+-- attribute and all values are removed, and the attribute SHALL be considered
+-- unassigned."
+--
+-- RFC 7643 §2.5 (Unassigned and Null Values): "Unassigned attributes, the
+-- null value, or an empty array [...] SHALL be considered to be equivalent
+-- in 'state'.  Assigning an attribute with the value 'null' [...] has the
+-- effect of making the attribute 'unassigned'."  Consequently, a @Replace@
+-- with an explicit @null@ value on a 'Maybe'-typed attribute here
+-- (displayname, externalid, active) unassigns it, exactly as @Remove@ does.
+applyUserOperation user (Operation Remove (Just (NormalPath (AttrPath _schema attr mSubAttr))) _value) =
   case attr of
     "username" -> throwError (badRequest Mutability Nothing)
     "displayname" -> pure $ user {displayName = Nothing}
     "externalid" -> pure $ user {externalId = Nothing}
     "active" -> pure $ user {active = Nothing}
     "roles" -> pure $ user {roles = []}
+    "emails" -> case mSubAttr of
+      -- Unfiltered remove of the multi-valued attribute itself.
+      Nothing -> pure $ user {emails = []}
+      -- A sub-attribute without a filter would have to hit every entry;
+      -- require the explicit value-path form instead (and keep @.value@
+      -- protected there).
+      Just _ ->
+        throwError
+          ( badRequest
+              InvalidPath
+              (Just "removing a sub-attribute of 'emails' requires a value-path filter, e.g. emails[type eq \\\"work\\\"].type")
+          )
     _ -> pure user
-applyUserOperation _ (Operation Remove (Just (IntoValuePath _ _)) _) = do
-  throwError (badRequest InvalidPath (Just "can not lens into multi-valued attributes yet"))
+-- RFC 7644 §3.5.2.2 (Remove Operation): "If the target location is a complex
+-- multi-valued attribute and a complex filter is specified based on the
+-- attribute's sub-attributes, the matching records are removed.
+-- Sub-attributes whose values have been removed SHALL be considered
+-- unassigned."  With a sub-attribute target (RFC 7644 §3.5.2: PATH = attrPath
+-- / valuePath [subAttr], Figure 7), the sub-attribute becomes *unassigned* on
+-- each matching entry and the record itself is kept -- implemented by reusing
+-- the null-unassignment of 'replaceEmailsValuePath' (RFC 7643 §2.5: remove of
+-- a sub-attribute is equivalent to assigning null).
+applyUserOperation user (Operation Remove (Just (IntoValuePath vp mSub)) _) =
+  case vp of
+    ValuePath (AttrPath _ attr _) _
+      | attr == "emails" -> case mSub of
+          Just (SubAttr sub)
+            | sub == "value" ->
+                throwError
+                  ( badRequest
+                      InvalidPath
+                      (Just "removing the 'value' sub-attribute of 'emails' is not supported; remove the whole entry instead")
+                  )
+            | otherwise -> replaceEmailsValuePath user vp mSub Null
+          Nothing -> pure user {emails = removeMatchingEmails vp (emails user)}
+      | otherwise ->
+          throwError
+            ( badRequest
+                InvalidPath
+                (Just "multi-valued PATCH is only supported for 'emails'")
+            )
+
+----------------------------------------------------------------------------
+-- Multi-valued 'emails' value-path PATCH
+--
+-- Previously any value-path target (e.g. @emails[type eq "work"].value@) was
+-- rejected with "can not lens into multi-valued attributes yet". We now support
+-- value-path PATCH for the @emails@ attribute only -- the single multi-valued
+-- attribute that Spar persists. Other multi-valued attributes
+-- (@phoneNumbers@, @ims@, ...) remain unsupported and still fail as before.
+--
+-- NOTE on "create on absent": RFC 7644 §3.5.2.3 says a value-path @Replace@
+-- that matches nothing is a no-op. Microsoft Entra ID, however, provisions the
+-- email address with an @Add@ against @emails[type eq "work"].value@ (Entra uses
+-- @Add@ for both insert and update -- see
+-- ),
+-- expecting the entry to be created if absent. Both 'addEmailsValuePath' and the
+-- @Replace@ path therefore route the @.value@ sub-attribute through
+-- 'replaceEmailValue', which deviates from the RFC: when the filter is
+-- @type eq @ and no entry matches, it appends
+-- @Email { typ = Just s, value = newVal, primary = Nothing }@.
+--
+-- @Remove@ honors the sub-attribute (RFC 7644 §3.5.2: PATH = valuePath
+-- [subAttr]): @remove emails[type eq "work"].type@\/@.primary@ unassigns just
+-- that field and keeps the entry; a filterless @remove emails@ clears all
+-- entries. @.value@ cannot be removed or nulled (400) -- the address is the
+-- record's identity in brig.
+
+-- | Textual form of an 'Email' address, for string comparison.
+emailValueText :: Email -> Text
+emailValueText (Email _ addr _) =
+  decodeUtf8 (EmailValidate.toByteString (unEmailAddress addr))
+
+-- | Does this 'Email' satisfy the given single-attribute 'Filter'? Supports the
+-- sub-attributes Entra and the spec use: @type@, @value@, @primary@. Any
+-- operator in 'compareStr's domain works for @type@\/@value@; @primary@ only
+-- supports @eq@\/@ne@. Unknown sub-attributes or a mismatched 'CompValue' type
+-- mean "no match".
+emailMatches :: Filter -> Email -> Bool
+emailMatches (FilterAttrCompare (AttrPath _ attr _) op cval) email
+  | attr == "type" = case cval of
+      ValString s -> compareStr op (fromMaybe "" (typ email)) s
+      _ -> False
+  | attr == "value" = case cval of
+      ValString s -> compareStr op (emailValueText email) s
+      _ -> False
+  | attr == "primary" = case cval of
+      ValBool b -> primaryMatches op b (primary email)
+      _ -> False
+  | otherwise = False
+
+-- | Compare a @primary@ filter value. Only @eq@\/@ne@ are meaningful.
+primaryMatches :: CompareOp -> Bool -> Maybe ScimBool -> Bool
+primaryMatches op b mp = case op of
+  OpEq -> mp == Just (ScimBool b)
+  OpNe -> mp /= Just (ScimBool b)
+  _ -> False
+
+-- | If the filter is @type eq @, return @Just s@; otherwise 'Nothing'.
+-- Drives create-on-absent for the @.value@ sub-attribute (see note above).
+filterTypeEq :: Filter -> Maybe Text
+filterTypeEq (FilterAttrCompare (AttrPath _ attr _) OpEq (ValString s))
+  | attr == "type" = Just s
+filterTypeEq _ = Nothing
+
+-- | Apply an update to each matching email. Never creates new entries.
+setEmailField :: Filter -> (Email -> Email) -> [Email] -> [Email]
+setEmailField flt update = map (\e -> if emailMatches flt e then update e else e)
+
+-- | Set the address of an 'Email'. Uses positional construction to avoid the
+-- bare 'value' selector, which is ambiguous (shared by 'Email', 'WithId' and
+-- 'Operation').
+setEmailAddress :: EmailAddress -> Email -> Email
+setEmailAddress newAddr (Email t _ p) = Email t newAddr p
+
+-- | Replace the @.value@ of every matching email. When nothing matches and the
+-- filter is @type eq @, append a new entry (create-on-absent; see note).
+replaceEmailValue :: Filter -> EmailAddress -> [Email] -> [Email]
+replaceEmailValue flt newAddr es
+  | any (emailMatches flt) es = setEmailField flt (setEmailAddress newAddr) es
+  | otherwise =
+      case filterTypeEq flt of
+        Just t -> es <> [Email (Just t) newAddr Nothing]
+        Nothing -> es
+
+-- | Replace each whole matching email with a new one; append if none match.
+--
+-- NOTE: every entry that matches the filter is overwritten with the same
+-- @newEmail@, so a filter matching several entries (e.g. two with
+-- @type eq "work"@, which Spar does not prevent) collapses them into
+-- duplicates. In practice each @type@ has at most one entry (the only mapping
+-- Entra uses), so this does not arise.
+replaceEmailEntry :: Filter -> Email -> [Email] -> [Email]
+replaceEmailEntry flt newEmail es
+  | any (emailMatches flt) es = setEmailField flt (const newEmail) es
+  | otherwise = es <> [newEmail]
+
+-- | Decode the operation value as one or more emails. A bare object is treated
+-- as a single-element list; an array is decoded as-is.
+decodeEmails :: (MonadError ScimError m) => Value -> m [Email]
+decodeEmails val = case fromJSON val of
+  Success (es' :: [Email]) -> pure es'
+  _ -> (: []) <$> resultToScimError (fromJSON val)
+
+-- | Handle an @Add@ on an @emails[...]@ value-path.
+--
+-- For the single-valued email sub-attributes (@.value@, @.type@, @.primary@) an
+-- @Add@ coincides with a @Replace@ (RFC 7644 §3.5.2.3): it sets the
+-- sub-attribute and, for @.value@, creates the entry on absent via
+-- 'replaceEmailValue'. For a whole-entry @Add@ (no sub-attribute) the value-path
+-- filter is intentionally ignored and the new entries are /appended/ rather than
+-- overwriting matches -- the concat semantics that distinguish @Add@ from
+-- @Replace@ for multi-valued attributes (where @Replace@ narrows the target set
+-- via the filter).
+addEmailsValuePath ::
+  (MonadError ScimError m) =>
+  User tag ->
+  ValuePath ->
+  Maybe SubAttr ->
+  Value ->
+  m (User tag)
+addEmailsValuePath user vp mSub val =
+  case mSub of
+    Just _ -> replaceEmailsValuePath user vp mSub val
+    Nothing -> do
+      newEmails <- decodeEmails val
+      pure user {emails = emails user <> newEmails}
+
+-- | Handle a @Replace@ on an @emails[...]@ value-path.
+replaceEmailsValuePath ::
+  (MonadError ScimError m) =>
+  User tag ->
+  ValuePath ->
+  Maybe SubAttr ->
+  Value ->
+  m (User tag)
+replaceEmailsValuePath user vp mSub val =
+  let flt = valuePathFilter vp
+      es = emails user
+   in case mSub of
+        Just (SubAttr sub)
+          -- RFC 7643 §2.5 (Unassigned and Null Values): "Assigning an
+          -- attribute with the value 'null' or an empty array [...] has the
+          -- effect of making the attribute 'unassigned'." Assigning an
+          -- explicit null therefore unassigns 'type'/'primary'; 'value' cannot
+          -- be unassigned (the address is the record's identity in brig).
+          | sub == "value" -> case val of
+              Null ->
+                throwError
+                  ( badRequest
+                      InvalidPath
+                      (Just "setting the 'value' sub-attribute of 'emails' to null is not supported; remove the whole entry instead")
+                  )
+              _ -> do
+                newAddr <- resultToScimError (fromJSON val)
+                pure user {emails = replaceEmailValue flt newAddr es}
+          | sub == "type" -> case val of
+              Null -> pure user {emails = setEmailField flt (\e -> e {typ = Nothing}) es}
+              _ -> do
+                t <- resultToScimError (fromJSON val)
+                pure user {emails = setEmailField flt (\e -> e {typ = Just t}) es}
+          | sub == "primary" -> case val of
+              Null -> pure user {emails = setEmailField flt (\e -> e {primary = Nothing}) es}
+              _ -> do
+                b <- resultToScimError (fromJSON val)
+                pure user {emails = setEmailField flt (\e -> e {primary = Just b}) es}
+          | otherwise ->
+              throwError
+                ( badRequest
+                    InvalidPath
+                    (Just "only the 'value', 'type' and 'primary' sub-attributes of 'emails' can be patched")
+                )
+        Nothing -> do
+          newEmails <- decodeEmails val
+          pure user {emails = foldr (replaceEmailEntry flt) es newEmails}
+
+-- | Drop every email matching the value-path filter (used by @Remove@ without a
+-- sub-attribute). A @Remove@ targeting a sub-attribute (e.g.
+-- @emails[type eq "work"].type@) is handled in 'applyUserOperation', which
+-- unassigns just that field and keeps the entry (RFC 7644 §3.5.2.2).
+removeMatchingEmails :: ValuePath -> [Email] -> [Email]
+removeMatchingEmails vp = filter (not . emailMatches (valuePathFilter vp))
 
 instance (UserTypes tag, FromJSON (User tag), Patchable (UserExtra tag)) => Patchable (User tag) where
   applyOperation user op@(Operation _ (Just (NormalPath (AttrPath schema _ _))) _)
diff --git a/libs/hscim/src/Web/Scim/Schema/User/Email.hs b/libs/hscim/src/Web/Scim/Schema/User/Email.hs
index 0b8bf7e919b..b893552ae3a 100644
--- a/libs/hscim/src/Web/Scim/Schema/User/Email.hs
+++ b/libs/hscim/src/Web/Scim/Schema/User/Email.hs
@@ -17,7 +17,6 @@
 
 module Web.Scim.Schema.User.Email where
 
-import Control.Applicative ((<|>))
 import Data.Aeson
 import Data.Text hiding (dropWhile, show)
 import Data.Text.Encoding (decodeUtf8, encodeUtf8)
@@ -53,12 +52,33 @@ instance ToJSON Email where
 emailToEmailAddress :: Email -> Email.EmailAddress
 emailToEmailAddress = unEmailAddress . value
 
-scimEmailsToEmailAddress :: [Email] -> Maybe Email.EmailAddress
-scimEmailsToEmailAddress es = pickPrimary es <|> pickFirst es
+-- | Pick the 'Email' entry whose address Wire stores: the single entry marked
+-- @primary@ (RFC 7643 §2.4 allows at most one), else the first entry.
+--
+-- Wire/brig holds at most one email per user, so the (possibly multi-valued)
+-- SCIM @emails@ attribute must be reduced to one entry. Selection rule:
+-- the entry marked @primary@ (RFC 7643 §2.4: @primary@ value @true@ MUST
+-- appear no more than once), else the first entry. Per RFC 7643 §2.4 an
+-- absent @primary@ is assumed @false@; with none marked primary, Wire
+-- deterministically picks the first entry (it must store exactly one email).
+--
+-- If more than one entry is marked @primary@ — a client-side protocol
+-- violation — this returns 'Left' with a descriptive message so the caller
+-- rejects the request instead of silently picking one.
+scimEmailsToEmail :: [Email] -> Either Text (Maybe Email)
+scimEmailsToEmail es = case primaries of
+  [primaryEmail] -> Right (Just primaryEmail)
+  [] -> Right (firstEntry es)
+  _ -> Left "More than one email is marked as primary; RFC 7643 §2.4 allows at most one."
   where
-    pickFirst [] = Nothing
-    pickFirst (e : _) = Just (unEmailAddress (value e))
+    firstEntry [] = Nothing
+    firstEntry (e : _) = Just e
 
-    pickPrimary = pickFirst . Prelude.filter isPrimary
+    primaries = Prelude.filter isPrimary es
 
     isPrimary e = primary e == Just (ScimBool True)
+
+-- | Reduce a list of SCIM emails to the single address Wire stores (see
+-- 'scimEmailsToEmail' for the selection rule).
+scimEmailsToEmailAddress :: [Email] -> Either Text (Maybe Email.EmailAddress)
+scimEmailsToEmailAddress = fmap (fmap emailToEmailAddress) . scimEmailsToEmail
diff --git a/libs/hscim/test/Test/Class/UserSpec.hs b/libs/hscim/test/Test/Class/UserSpec.hs
index 6a46738dccc..de1fd973efa 100644
--- a/libs/hscim/test/Test/Class/UserSpec.hs
+++ b/libs/hscim/test/Test/Class/UserSpec.hs
@@ -328,6 +328,33 @@ spec = with app $ do
           }]}|]
           `shouldRespondWith` 400
         get "/0" `shouldRespondWith` smallUserGet {matchStatus = 200}
+      it "patches a multi-valued 'emails' value-path end-to-end" $ do
+        post "/" newBarbara `shouldRespondWith` 201
+        _ <- put "/0" smallUser
+        patch
+          "/0"
+          [scim|{
+            "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
+            "Operations": [{
+              "op": "Replace",
+              "path": "emails[type eq \"work\"].value",
+              "value": "x@y.com"
+            }]
+          }|]
+          `shouldRespondWith` [scim|{
+            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+            "userName": "bjensen",
+            "displayName": "bjensen2",
+            "emails": [{"value":"x@y.com","type":"work"}],
+            "id": "0",
+            "meta": {
+              "resourceType": "User",
+              "location": "https://example.com/Users/id",
+              "created": "2018-01-01T00:00:00Z",
+              "version": "W/\"testVersion\"",
+              "lastModified": "2018-01-01T00:00:00Z"
+            }
+          }|]
     describe "Remove" $ do
       it "fails if no target" $ do
         post "/" newBarbara `shouldRespondWith` 201
diff --git a/libs/hscim/test/Test/Schema/PatchOpSpec.hs b/libs/hscim/test/Test/Schema/PatchOpSpec.hs
index 2e9a0415316..f3e21bb08b9 100644
--- a/libs/hscim/test/Test/Schema/PatchOpSpec.hs
+++ b/libs/hscim/test/Test/Schema/PatchOpSpec.hs
@@ -140,3 +140,13 @@ spec = do
               "members[value eq \"2819c223-7f76-453a-919d-413861904646\"].displayname"
             ]
       for_ examples $ \p -> it ("parses " ++ show p) $ rPath <$> parseOnly (pPath (supportedSchemas @PatchTestTag)) p `shouldBe` Right (decodeUtf8 p)
+    it "keeps an explicit null 'value' distinct from an absent 'value' (RFC 7643 §2.5)" $ do
+      let p = either (error "unparseable path") id (parsePath [User20] "emails[type eq \"work\"].type")
+      Aeson.parseEither
+        (operationFromJSON [User20])
+        [scim| {"op":"replace","path":"emails[type eq \"work\"].type","value":null} |]
+        `shouldBe` Right (Operation Replace (Just p) (Just Aeson.Null))
+      Aeson.parseEither
+        (operationFromJSON [User20])
+        [scim| {"op":"replace","path":"emails[type eq \"work\"].type"} |]
+        `shouldBe` Right (Operation Replace (Just p) Nothing)
diff --git a/libs/hscim/test/Test/Schema/UserSpec.hs b/libs/hscim/test/Test/Schema/UserSpec.hs
index 5aea6a427d8..dc76101c33d 100644
--- a/libs/hscim/test/Test/Schema/UserSpec.hs
+++ b/libs/hscim/test/Test/Schema/UserSpec.hs
@@ -81,7 +81,7 @@ spec = do
         true = Just (ScimBool True)
 
     it "returns Nothing if empty" $ do
-      scimEmailsToEmailAddress [] `shouldBe` Nothing
+      scimEmailsToEmailAddress [] `shouldBe` Right Nothing
 
     it "returns first primary if it exists" $ do
       scimEmailsToEmailAddress
@@ -89,19 +89,33 @@ spec = do
           Email Nothing (EmailAddress adr2) false2,
           Email (Just "this is ignored") (EmailAddress adr3) true
         ]
-        `shouldBe` Just adr3
+        `shouldBe` Right (Just adr3)
 
     it "returns first entry if no primary exists" $ do
       scimEmailsToEmailAddress
         [ Email Nothing (EmailAddress adr1) false1,
           Email Nothing (EmailAddress adr2) false2
         ]
-        `shouldBe` Just adr1
+        `shouldBe` Right (Just adr1)
       scimEmailsToEmailAddress
         [ Email Nothing (EmailAddress adr1) false2,
           Email Nothing (EmailAddress adr2) false1
         ]
-        `shouldBe` Just adr1
+        `shouldBe` Right (Just adr1)
+
+    it "rejects when more than one email is primary" $ do
+      scimEmailsToEmailAddress
+        [ Email Nothing (EmailAddress adr1) true,
+          Email Nothing (EmailAddress adr2) true
+        ]
+        `shouldBe` Left "More than one email is marked as primary; RFC 7643 §2.4 allows at most one."
+
+    it "does not reject when one primary is true and another is false" $ do
+      scimEmailsToEmailAddress
+        [ Email Nothing (EmailAddress adr1) true,
+          Email Nothing (EmailAddress adr2) false2
+        ]
+        `shouldBe` Right (Just adr1)
 
   describe "applyPatch" $ do
     it "only applies patch for supported fields" $ do
@@ -155,6 +169,184 @@ spec = do
       let patchOp = PatchOp [operation]
       User.extra <$> User.applyPatch user patchOp `shouldBe` Right (KeyMap.singleton "programmingLanguage" "haskell")
 
+  describe "applyPatch (emails value-path)" $ do
+    let mkEmail typ' raw = case validate raw of
+          Right a -> Email.Email (Just typ') (Email.EmailAddress a) Nothing
+          Left _ -> error $ "invalid email in test: " <> show raw
+        mkEmailPrimary typ' raw b = case validate raw of
+          Right a -> Email.Email (Just typ') (Email.EmailAddress a) (Just (ScimBool b))
+          Left _ -> error $ "invalid email in test: " <> show raw
+        mkUser :: [Email.Email] -> User PatchTag
+        mkUser es = (User.empty [] "hello" KeyMap.empty :: User PatchTag) {emails = es}
+        emailTypePath = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"].type"
+        emailPrimaryPath = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"].primary"
+        emailValuePath = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"].value"
+    it "creates a work email via Replace when none matches" $ do
+      let Right p = emailValuePath
+          operation = Operation Replace (Just p) (Just (String "x@y.com"))
+          result = User.applyPatch (mkUser []) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "x@y.com"]
+    it "updates an existing matching email's value" $ do
+      let Right p = emailValuePath
+          operation = Operation Replace (Just p) (Just (String "new@example.com"))
+          result = User.applyPatch (mkUser [mkEmail "work" "old@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      length (emails patched) `shouldBe` 1
+      emails patched `shouldBe` [mkEmail "work" "new@example.com"]
+    it "fails when no value is provided" $ do
+      let Right p = emailValuePath
+          operation = Operation Replace (Just p) Nothing
+          result = User.applyPatch (mkUser []) (PatchOp [operation])
+      result `shouldSatisfy` isLeft
+    it "removes the whole matching email entry" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"]"
+          operation = Operation Remove (Just p) Nothing
+          result = User.applyPatch (mkUser [mkEmail "work" "w@example.com", mkEmail "home" "h@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "home" "h@example.com"]
+    it "is case-insensitive in the path" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "EMAILS[TYPE EQ \"work\"].VALUE"
+          operation = Operation Replace (Just p) (Just (String "ci@example.com"))
+          result = User.applyPatch (mkUser []) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "ci@example.com"]
+    it "still rejects unsupported multi-valued attributes" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "phoneNumbers[type eq \"x\"].value"
+          operation = Operation Replace (Just p) (Just (String "+15555550100"))
+          result = User.applyPatch (mkUser []) (PatchOp [operation])
+      result `shouldSatisfy` isLeft
+    it "updates the 'type' sub-attribute of matching emails" $ do
+      let Right p = emailTypePath
+          operation = Operation Replace (Just p) (Just (String "custom"))
+          result = User.applyPatch (mkUser [mkEmail "work" "a@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "custom" "a@example.com"]
+    it "updates the 'primary' sub-attribute of matching emails" $ do
+      let Right p = emailPrimaryPath
+          operation = Operation Replace (Just p) (Just (Bool True))
+          result = User.applyPatch (mkUser [mkEmail "work" "a@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmailPrimary "work" "a@example.com" True]
+    it "replaces a whole matching email entry from an object value" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"]"
+          newVal = object ["value" .= String "new@example.com", "type" .= String "work"]
+          operation = Operation Replace (Just p) (Just newVal)
+          result = User.applyPatch (mkUser [mkEmail "work" "old@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "new@example.com"]
+    it "replaces a whole matching email entry from an array value" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"]"
+          newVal = toJSON [object ["value" .= String "arr@example.com", "type" .= String "work"]]
+          operation = Operation Replace (Just p) (Just newVal)
+          result = User.applyPatch (mkUser [mkEmail "work" "old@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "arr@example.com"]
+    it "Add on .value updates an existing matching email" $ do
+      let Right p = emailValuePath
+          operation = Operation Add (Just p) (Just (String "new@example.com"))
+          result = User.applyPatch (mkUser [mkEmail "work" "old@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "new@example.com"]
+    it "Add on .value creates a work email when none matches" $ do
+      let Right p = emailValuePath
+          operation = Operation Add (Just p) (Just (String "x@y.com"))
+          result = User.applyPatch (mkUser []) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "x@y.com"]
+    it "Add on a whole emails entry appends without overwriting" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"]"
+          newVal = object ["value" .= String "added@example.com", "type" .= String "work"]
+          operation = Operation Add (Just p) (Just newVal)
+          result = User.applyPatch (mkUser [mkEmail "work" "keep@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "keep@example.com", mkEmail "work" "added@example.com"]
+    it "removes the 'type' sub-attribute, keeping the entry and 'primary'" $ do
+      let Right p = emailTypePath
+          operation = Operation Remove (Just p) Nothing
+          result = User.applyPatch (mkUser [mkEmailPrimary "work" "w@example.com" True]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+          Right addr = validate "w@example.com"
+      emails patched `shouldBe` [Email.Email Nothing (Email.EmailAddress addr) (Just (ScimBool True))]
+    it "removes the 'primary' sub-attribute, keeping the entry and 'type'" $ do
+      let Right p = emailPrimaryPath
+          operation = Operation Remove (Just p) Nothing
+          result = User.applyPatch (mkUser [mkEmailPrimary "work" "w@example.com" True]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "w@example.com"]
+    it "rejects removing the 'value' sub-attribute" $ do
+      let Right p = emailValuePath
+          operation = Operation Remove (Just p) Nothing
+          result = User.applyPatch (mkUser [mkEmail "work" "w@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isLeft
+    it "unassigns 'type' on an explicit null value (RFC 7643 §2.5)" $ do
+      let Right p = emailTypePath
+          operation = Operation Replace (Just p) (Just Null)
+          result = User.applyPatch (mkUser [mkEmailPrimary "work" "w@example.com" True]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+          Right addr = validate "w@example.com"
+      emails patched `shouldBe` [Email.Email Nothing (Email.EmailAddress addr) (Just (ScimBool True))]
+    it "unassigns 'primary' on an explicit null value (RFC 7643 §2.5)" $ do
+      let Right p = emailPrimaryPath
+          operation = Operation Replace (Just p) (Just Null)
+          result = User.applyPatch (mkUser [mkEmailPrimary "work" "w@example.com" True]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "w@example.com"]
+    it "rejects an explicit null value on the 'value' sub-attribute" $ do
+      let Right p = emailValuePath
+          operation = Operation Replace (Just p) (Just Null)
+          result = User.applyPatch (mkUser [mkEmail "work" "w@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isLeft
+    it "removes all emails on a filterless remove (RFC 7644 §3.5.2.2)" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails"
+          operation = Operation Remove (Just p) Nothing
+          result = User.applyPatch (mkUser [mkEmail "work" "w@example.com", mkEmail "home" "h@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` []
+    it "Add with an explicit null value unassigns 'type' like Replace" $ do
+      let Right p = emailTypePath
+          operation = Operation Add (Just p) (Just Null)
+          result = User.applyPatch (mkUser [mkEmailPrimary "work" "w@example.com" True]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+          Right addr = validate "w@example.com"
+      emails patched `shouldBe` [Email.Email Nothing (Email.EmailAddress addr) (Just (ScimBool True))]
+    it "rejects removing an unknown emails sub-attribute" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"].displayname"
+          operation = Operation Remove (Just p) Nothing
+          result = User.applyPatch (mkUser [mkEmail "work" "w@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isLeft
+    it "rejects removing an emails sub-attribute without a filter" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails.type"
+          operation = Operation Remove (Just p) Nothing
+          result = User.applyPatch (mkUser [mkEmail "work" "w@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isLeft
+    it "unassigns a Maybe-typed attribute on an explicit null value (RFC 7643 §2.5)" $ do
+      let setPath = Just (NormalPath (AttrPath Nothing "displayname" Nothing))
+          result =
+            User.applyPatch (mkUser []) . PatchOp $
+              [ Operation Replace setPath (Just (String "Old")),
+                Operation Replace setPath (Just Null)
+              ]
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      displayName patched `shouldBe` Nothing
   describe "JSON serialization" $ do
     it "handles all fields" $ do
       require prop_roundtrip
diff --git a/libs/wire-api/src/Wire/API/User/Scim.hs b/libs/wire-api/src/Wire/API/User/Scim.hs
index cda70e95803..e2115dfdbd7 100644
--- a/libs/wire-api/src/Wire/API/User/Scim.hs
+++ b/libs/wire-api/src/Wire/API/User/Scim.hs
@@ -358,7 +358,15 @@ data ValidScimUser = ValidScimUser
   { externalId :: ValidScimId,
     handle :: Handle,
     name :: BT.Name,
-    emails :: [EmailAddress],
+    -- | The (at most one) email address Wire stores for this user: brig keeps a
+    -- single email, so the SCIM @emails@ list is reduced to one entry by
+    -- @scimEmailsToEmail@ (the entry marked @primary@, else the
+    -- first).  'emailType' and 'emailPrimary' are the @type@\/@primary@
+    -- metadata of that same single entry ('Nothing' = not supplied, echoed as
+    -- absent).
+    emails :: Maybe EmailAddress,
+    emailType :: Maybe Text,
+    emailPrimary :: Maybe Bool,
     richInfo :: RI.RichInfo,
     active :: Bool,
     locale :: Maybe Locale,
diff --git a/libs/wire-subsystems/src/Wire/ScimUserTimesStore.hs b/libs/wire-subsystems/src/Wire/ScimUserMetaStore.hs
similarity index 50%
rename from libs/wire-subsystems/src/Wire/ScimUserTimesStore.hs
rename to libs/wire-subsystems/src/Wire/ScimUserMetaStore.hs
index 4a13c517e90..50a62c231be 100644
--- a/libs/wire-subsystems/src/Wire/ScimUserTimesStore.hs
+++ b/libs/wire-subsystems/src/Wire/ScimUserMetaStore.hs
@@ -17,8 +17,9 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Wire.ScimUserTimesStore
-  ( ScimUserTimesStore (..),
+module Wire.ScimUserMetaStore
+  ( ScimUserMeta (..),
+    ScimUserMetaStore (..),
     write,
     read,
     readMulti,
@@ -28,15 +29,28 @@ where
 
 import Data.Id (UserId)
 import Data.Json.Util (UTCTimeMillis)
-import Imports (Maybe)
+import Imports (Bool, Eq, Maybe, Show, Text)
 import Polysemy
 import Web.Scim.Schema.Common (WithId)
 import Web.Scim.Schema.Meta (WithMeta)
 
-data ScimUserTimesStore m a where
-  Write :: WithMeta (WithId UserId t) -> ScimUserTimesStore m ()
-  Read :: UserId -> ScimUserTimesStore m (Maybe (UTCTimeMillis, UTCTimeMillis))
-  ReadMulti :: [UserId] -> ScimUserTimesStore m [(UserId, UTCTimeMillis, UTCTimeMillis)]
-  Delete :: UserId -> ScimUserTimesStore m ()
+-- | SCIM user metadata stored under a user id: creation and last-update time,
+-- plus the SCIM email metadata (@type@, @primary@) of the stored email entry.
+--
+-- The backing Cassandra table is still called @spar.scim_user_times@ (renaming
+-- it would require a migration); the store is no longer just about times.
+data ScimUserMeta = ScimUserMeta
+  { scimUserMetaCreated :: UTCTimeMillis,
+    scimUserMetaLastUpdated :: UTCTimeMillis,
+    scimUserMetaEmailType :: Maybe Text,
+    scimUserMetaEmailPrimary :: Maybe Bool
+  }
+  deriving (Eq, Show)
+
+data ScimUserMetaStore m a where
+  Write :: Maybe Text -> Maybe Bool -> WithMeta (WithId UserId t) -> ScimUserMetaStore m ()
+  Read :: UserId -> ScimUserMetaStore m (Maybe ScimUserMeta)
+  ReadMulti :: [UserId] -> ScimUserMetaStore m [(UserId, ScimUserMeta)]
+  Delete :: UserId -> ScimUserMetaStore m ()
 
-makeSem ''ScimUserTimesStore
+makeSem ''ScimUserMetaStore
diff --git a/libs/wire-subsystems/src/Wire/ScimUserMetaStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ScimUserMetaStore/Cassandra.hs
new file mode 100644
index 00000000000..591c03fac92
--- /dev/null
+++ b/libs/wire-subsystems/src/Wire/ScimUserMetaStore/Cassandra.hs
@@ -0,0 +1,102 @@
+-- Disabling to stop warnings on HasCallStack
+{-# OPTIONS_GHC -Wno-redundant-constraints #-}
+
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2022 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Wire.ScimUserMetaStore.Cassandra
+  ( scimUserMetaStoreToCassandra,
+  )
+where
+
+import Cassandra as Cas
+import Data.Id
+import Data.Json.Util (UTCTimeMillis, toUTCTimeMillis)
+import Imports
+import Polysemy
+import Web.Scim.Schema.Common (WithId (..))
+import Web.Scim.Schema.Meta (Meta (..), WithMeta (..))
+import Wire.ScimUserMetaStore (ScimUserMeta (..), ScimUserMetaStore (..))
+
+scimUserMetaStoreToCassandra :: forall m r a. (MonadClient m, Member (Embed m) r) => Sem (ScimUserMetaStore ': r) a -> Sem r a
+scimUserMetaStoreToCassandra =
+  interpret $
+    embed @m . \case
+      Write emailType emailPrimary wm -> writeScimUserMeta emailType emailPrimary wm
+      Read uid -> readScimUserMeta uid
+      ReadMulti uids -> readScimUserMetaMulti uids
+      Delete uid -> deleteScimUserMeta uid
+
+----------------------------------------------------------------------
+-- SCIM user records
+--
+-- docs/developer/scim/storage.md {#DevScimStorageUsers}
+
+-- | Store creation and last-update time from the scim metadata under a user
+-- id, together with the SCIM email metadata (@type@, @primary@) of the stored
+-- email entry (if any was supplied by the IdP).
+writeScimUserMeta :: (HasCallStack, MonadClient m) => Maybe Text -> Maybe Bool -> WithMeta (WithId UserId a) -> m ()
+writeScimUserMeta emailType emailPrimary (WithMeta meta (WithId uid _)) =
+  retry x5 . write ins $
+    params
+      LocalQuorum
+      ( uid,
+        toUTCTimeMillis $ created meta,
+        toUTCTimeMillis $ lastModified meta,
+        emailType,
+        emailPrimary
+      )
+  where
+    ins :: PrepQuery W (UserId, UTCTimeMillis, UTCTimeMillis, Maybe Text, Maybe Bool) ()
+    ins = "INSERT INTO scim_user_times (uid, created_at, last_updated_at, email_type, email_primary) VALUES (?, ?, ?, ?, ?)"
+
+-- | Read creation and last-update time (and SCIM email metadata) from the
+-- database for a given user id.
+readScimUserMeta :: (HasCallStack, MonadClient m) => UserId -> m (Maybe ScimUserMeta)
+readScimUserMeta uid = do
+  fmap rowToScimUserMeta
+    <$> retry x1 (query1 sel $ params LocalQuorum (Identity uid))
+  where
+    sel :: PrepQuery R (Identity UserId) (UTCTimeMillis, UTCTimeMillis, Maybe Text, Maybe Bool)
+    sel = "SELECT created_at, last_updated_at, email_type, email_primary FROM scim_user_times WHERE uid = ?"
+
+readScimUserMetaMulti :: (HasCallStack, MonadClient m) => [UserId] -> m [(UserId, ScimUserMeta)]
+readScimUserMetaMulti uid = do
+  fmap rowToUserMetaMulti
+    <$> retry x1 (query sel $ params LocalQuorum (Identity uid))
+  where
+    sel :: PrepQuery R (Identity [UserId]) (UserId, UTCTimeMillis, UTCTimeMillis, Maybe Text, Maybe Bool)
+    sel = "SELECT uid, created_at, last_updated_at, email_type, email_primary FROM scim_user_times WHERE uid IN ?"
+
+rowToScimUserMeta :: (UTCTimeMillis, UTCTimeMillis, Maybe Text, Maybe Bool) -> ScimUserMeta
+rowToScimUserMeta (created_, lastUpdated, emailType, emailPrimary) =
+  ScimUserMeta created_ lastUpdated emailType emailPrimary
+
+rowToUserMetaMulti :: (UserId, UTCTimeMillis, UTCTimeMillis, Maybe Text, Maybe Bool) -> (UserId, ScimUserMeta)
+rowToUserMetaMulti (uid, created_, lastUpdated, emailType, emailPrimary) =
+  (uid, ScimUserMeta created_ lastUpdated emailType emailPrimary)
+
+-- | Delete a SCIM user's access times by id.
+-- You'll also want to ensure they are deleted in Brig and in the SAML Users table.
+deleteScimUserMeta ::
+  (HasCallStack, MonadClient m) =>
+  UserId ->
+  m ()
+deleteScimUserMeta uid = retry x5 . write del $ params LocalQuorum (Identity uid)
+  where
+    del :: PrepQuery W (Identity UserId) ()
+    del = "DELETE FROM scim_user_times WHERE uid = ?"
diff --git a/libs/wire-subsystems/src/Wire/ScimUserTimesStore/Mem.hs b/libs/wire-subsystems/src/Wire/ScimUserMetaStore/Mem.hs
similarity index 65%
rename from libs/wire-subsystems/src/Wire/ScimUserTimesStore/Mem.hs
rename to libs/wire-subsystems/src/Wire/ScimUserMetaStore/Mem.hs
index a1de707e047..2fb47f02c62 100644
--- a/libs/wire-subsystems/src/Wire/ScimUserTimesStore/Mem.hs
+++ b/libs/wire-subsystems/src/Wire/ScimUserMetaStore/Mem.hs
@@ -17,27 +17,34 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Wire.ScimUserTimesStore.Mem
-  ( scimUserTimesStoreToMem,
+module Wire.ScimUserMetaStore.Mem
+  ( scimUserMetaStoreToMem,
   )
 where
 
 import Data.Id (UserId)
-import Data.Json.Util (UTCTimeMillis, toUTCTimeMillis)
+import Data.Json.Util (toUTCTimeMillis)
 import Data.Map qualified as M
 import Imports
 import Polysemy
 import Polysemy.State
 import Web.Scim.Schema.Common (WithId (WithId))
 import Web.Scim.Schema.Meta (WithMeta (WithMeta), created, lastModified)
-import Wire.ScimUserTimesStore
+import Wire.ScimUserMetaStore
 
-scimUserTimesStoreToMem ::
-  Sem (ScimUserTimesStore ': r) a ->
-  Sem r (Map UserId (UTCTimeMillis, UTCTimeMillis), a)
-scimUserTimesStoreToMem = (runState mempty .) $
+scimUserMetaStoreToMem ::
+  Sem (ScimUserMetaStore ': r) a ->
+  Sem r (Map UserId ScimUserMeta, a)
+scimUserMetaStoreToMem = (runState mempty .) $
   reinterpret $ \case
-    Write (WithMeta meta (WithId uid _)) -> modify $ M.insert uid (toUTCTimeMillis $ created meta, toUTCTimeMillis $ lastModified meta)
+    Write emailType emailPrimary (WithMeta meta (WithId uid _)) ->
+      modify $
+        M.insert uid $
+          ScimUserMeta
+            (toUTCTimeMillis $ created meta)
+            (toUTCTimeMillis $ lastModified meta)
+            emailType
+            emailPrimary
     Read uid -> gets $ M.lookup uid
-    ReadMulti uids -> gets $ map (\(u, (a, b)) -> (u, a, b)) . filter ((`elem` uids) . fst) . M.toList
+    ReadMulti uids -> gets $ filter ((`elem` uids) . fst) . M.toList
     Delete uid -> modify $ M.delete uid
diff --git a/libs/wire-subsystems/src/Wire/ScimUserTimesStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ScimUserTimesStore/Cassandra.hs
deleted file mode 100644
index fe669d18136..00000000000
--- a/libs/wire-subsystems/src/Wire/ScimUserTimesStore/Cassandra.hs
+++ /dev/null
@@ -1,87 +0,0 @@
--- Disabling to stop warnings on HasCallStack
-{-# OPTIONS_GHC -Wno-redundant-constraints #-}
-
--- This file is part of the Wire Server implementation.
---
--- Copyright (C) 2022 Wire Swiss GmbH 
---
--- This program is free software: you can redistribute it and/or modify it under
--- the terms of the GNU Affero General Public License as published by the Free
--- Software Foundation, either version 3 of the License, or (at your option) any
--- later version.
---
--- This program is distributed in the hope that it will be useful, but WITHOUT
--- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
--- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
--- details.
---
--- You should have received a copy of the GNU Affero General Public License along
--- with this program. If not, see .
-
-module Wire.ScimUserTimesStore.Cassandra
-  ( scimUserTimesStoreToCassandra,
-  )
-where
-
-import Cassandra as Cas
-import Data.Id
-import Data.Json.Util (UTCTimeMillis, toUTCTimeMillis)
-import Imports
-import Polysemy
-import Web.Scim.Schema.Common (WithId (..))
-import Web.Scim.Schema.Meta (Meta (..), WithMeta (..))
-import Wire.ScimUserTimesStore (ScimUserTimesStore (..))
-
-scimUserTimesStoreToCassandra :: forall m r a. (MonadClient m, Member (Embed m) r) => Sem (ScimUserTimesStore ': r) a -> Sem r a
-scimUserTimesStoreToCassandra =
-  interpret $
-    embed @m . \case
-      Write wm -> writeScimUserTimes wm
-      Read uid -> readScimUserTimes uid
-      ReadMulti uids -> readScimUserTimesMulti uids
-      Delete uid -> deleteScimUserTimes uid
-
-----------------------------------------------------------------------
--- SCIM user records
---
--- docs/developer/scim/storage.md {#DevScimStorageUsers}
-
--- | Store creation and last-update time from the scim metadata under a user id.
-writeScimUserTimes :: (HasCallStack, MonadClient m) => WithMeta (WithId UserId a) -> m ()
-writeScimUserTimes (WithMeta meta (WithId uid _)) =
-  retry x5 . write ins $
-    params
-      LocalQuorum
-      ( uid,
-        toUTCTimeMillis $ created meta,
-        toUTCTimeMillis $ lastModified meta
-      )
-  where
-    ins :: PrepQuery W (UserId, UTCTimeMillis, UTCTimeMillis) ()
-    ins = "INSERT INTO scim_user_times (uid, created_at, last_updated_at) VALUES (?, ?, ?)"
-
--- | Read creation and last-update time from database for a given user id.
-readScimUserTimes :: (HasCallStack, MonadClient m) => UserId -> m (Maybe (UTCTimeMillis, UTCTimeMillis))
-readScimUserTimes uid = do
-  retry x1 . query1 sel $ params LocalQuorum (Identity uid)
-  where
-    sel :: PrepQuery R (Identity UserId) (UTCTimeMillis, UTCTimeMillis)
-    sel = "SELECT created_at, last_updated_at FROM scim_user_times WHERE uid = ?"
-
-readScimUserTimesMulti :: (HasCallStack, MonadClient m) => [UserId] -> m [(UserId, UTCTimeMillis, UTCTimeMillis)]
-readScimUserTimesMulti uid = do
-  retry x1 . query sel $ params LocalQuorum (Identity uid)
-  where
-    sel :: PrepQuery R (Identity [UserId]) (UserId, UTCTimeMillis, UTCTimeMillis)
-    sel = "SELECT uid, created_at, last_updated_at FROM scim_user_times WHERE uid IN ?"
-
--- | Delete a SCIM user's access times by id.
--- You'll also want to ensure they are deleted in Brig and in the SAML Users table.
-deleteScimUserTimes ::
-  (HasCallStack, MonadClient m) =>
-  UserId ->
-  m ()
-deleteScimUserTimes uid = retry x5 . write del $ params LocalQuorum (Identity uid)
-  where
-    del :: PrepQuery W (Identity UserId) ()
-    del = "DELETE FROM scim_user_times WHERE uid = ?"
diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal
index be21d0b50c6..f7627036f1c 100644
--- a/libs/wire-subsystems/wire-subsystems.cabal
+++ b/libs/wire-subsystems/wire-subsystems.cabal
@@ -443,9 +443,9 @@ library
     Wire.ScimSubsystem
     Wire.ScimSubsystem.Error
     Wire.ScimSubsystem.Interpreter
-    Wire.ScimUserTimesStore
-    Wire.ScimUserTimesStore.Cassandra
-    Wire.ScimUserTimesStore.Mem
+    Wire.ScimUserMetaStore
+    Wire.ScimUserMetaStore.Cassandra
+    Wire.ScimUserMetaStore.Mem
     Wire.ServiceStore
     Wire.ServiceStore.Cassandra
     Wire.SessionStore
diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal
index 962388000ed..5a0fa138ebf 100644
--- a/services/spar/spar.cabal
+++ b/services/spar/spar.cabal
@@ -51,6 +51,7 @@ library
     Spar.Schema.V20
     Spar.Schema.V21
     Spar.Schema.V22
+    Spar.Schema.V23
     Spar.Schema.V3
     Spar.Schema.V4
     Spar.Schema.V5
diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs
index aa5eac03f40..f18e882b496 100644
--- a/services/spar/src/Spar/API.hs
+++ b/services/spar/src/Spar/API.hs
@@ -131,8 +131,8 @@ import Wire.SamlProtocolSettings (SamlProtocolSettings)
 import qualified Wire.SamlProtocolSettings as SamlProtocolSettings
 import Wire.ScimExternalIdStore (ScimExternalIdStore)
 import Wire.ScimSubsystem
-import Wire.ScimUserTimesStore (ScimUserTimesStore)
-import qualified Wire.ScimUserTimesStore as ScimUserTimesStore
+import Wire.ScimUserMetaStore (ScimUserMetaStore)
+import qualified Wire.ScimUserMetaStore as ScimUserMetaStore
 import Wire.Sem.Logger (Logger)
 import qualified Wire.Sem.Logger as Logger
 import Wire.Sem.Now (Now)
@@ -168,7 +168,7 @@ api ::
     Member AReqIDStore r,
     Member VerdictFormatStore r,
     Member ScimExternalIdStore r,
-    Member ScimUserTimesStore r,
+    Member ScimUserMetaStore r,
     Member ScimTokenStore r,
     Member ScimSubsystem r,
     Member IdPSubsystem r,
@@ -263,7 +263,7 @@ apiINTERNAL ::
     Member IdPConfigStore r,
     Member (Error SparError) r,
     Member SAMLUserStore r,
-    Member ScimUserTimesStore r,
+    Member ScimUserMetaStore r,
     Member (Logger (Msg -> Msg)) r,
     Member Random r,
     Member GalleyAPIAccess r,
@@ -1150,7 +1150,7 @@ internalPutSsoSettings SsoSettings {defaultSsoCode = Just code} =
     *> DefaultSsoCode.store code
       $> NoContent
 
-internalGetScimUserInfo :: (Member ScimUserTimesStore r) => UserId -> Sem r ScimUserInfo
+internalGetScimUserInfo :: (Member ScimUserMetaStore r) => UserId -> Sem r ScimUserInfo
 internalGetScimUserInfo uid = do
-  t <- fmap fst <$> ScimUserTimesStore.read uid
+  t <- fmap (.scimUserMetaCreated) <$> ScimUserMetaStore.read uid
   pure $ ScimUserInfo uid t
diff --git a/services/spar/src/Spar/CanonicalInterpreter.hs b/services/spar/src/Spar/CanonicalInterpreter.hs
index f8d444b801f..72155ab880f 100644
--- a/services/spar/src/Spar/CanonicalInterpreter.hs
+++ b/services/spar/src/Spar/CanonicalInterpreter.hs
@@ -75,8 +75,8 @@ import Wire.ScimExternalIdStore (ScimExternalIdStore)
 import Wire.ScimExternalIdStore.Cassandra (scimExternalIdStoreToCassandra)
 import Wire.ScimSubsystem
 import Wire.ScimSubsystem.Interpreter
-import Wire.ScimUserTimesStore (ScimUserTimesStore)
-import Wire.ScimUserTimesStore.Cassandra (scimUserTimesStoreToCassandra)
+import Wire.ScimUserMetaStore (ScimUserMetaStore)
+import Wire.ScimUserMetaStore.Cassandra (scimUserMetaStoreToCassandra)
 import Wire.Sem.Logger.TinyLog (loggerToTinyLog, stringLoggerToTinyLog)
 import Wire.Sem.Now (Now)
 import Wire.Sem.Now.IO (nowToIO)
@@ -104,7 +104,7 @@ type LowerLevelCanonicalEffs =
      Error IdPSubsystemError,
      Error ScimSubsystemError,
      ScimExternalIdStore,
-     ScimUserTimesStore,
+     ScimUserMetaStore,
      ScimTokenStore,
      DefaultSsoCode,
      IdPConfigStore,
@@ -147,7 +147,7 @@ runSparToIO ctx =
     . idPToCassandra
     . defaultSsoCodeToCassandra
     . scimTokenStoreToCassandra
-    . scimUserTimesStoreToCassandra
+    . scimUserMetaStoreToCassandra
     . scimExternalIdStoreToCassandra
     . mapScimSubsystemErrors
     . mapIdPSubsystemErrors
diff --git a/services/spar/src/Spar/Schema/Run.hs b/services/spar/src/Spar/Schema/Run.hs
index 49482353c4b..b1b55bad6af 100644
--- a/services/spar/src/Spar/Schema/Run.hs
+++ b/services/spar/src/Spar/Schema/Run.hs
@@ -37,6 +37,7 @@ import qualified Spar.Schema.V2 as V2
 import qualified Spar.Schema.V20 as V20
 import qualified Spar.Schema.V21 as V21
 import qualified Spar.Schema.V22 as V22
+import qualified Spar.Schema.V23 as V23
 import qualified Spar.Schema.V3 as V3
 import qualified Spar.Schema.V4 as V4
 import qualified Spar.Schema.V5 as V5
@@ -86,7 +87,8 @@ migrations =
     V19.migration,
     V20.migration,
     V21.migration,
-    V22.migration
+    V22.migration,
+    V23.migration
     -- TODO: Add a migration that removes unused fields
     -- (we don't want to risk running a migration which would
     -- effectively break the currently deployed spar service)
diff --git a/services/spar/src/Spar/Schema/V23.hs b/services/spar/src/Spar/Schema/V23.hs
new file mode 100644
index 00000000000..a599fa8238f
--- /dev/null
+++ b/services/spar/src/Spar/Schema/V23.hs
@@ -0,0 +1,32 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Spar.Schema.V23
+  ( migration,
+  )
+where
+
+import Cassandra.Schema
+import Imports
+import Text.RawString.QQ
+
+migration :: Migration
+migration = Migration 23 "Store SCIM email metadata (type, primary) in scim_user_times" $ do
+  schema'
+    [r|
+        ALTER TABLE scim_user_times ADD (email_type text, email_primary boolean);
+      |]
diff --git a/services/spar/src/Spar/Scim.hs b/services/spar/src/Spar/Scim.hs
index 3638c6e9043..513b14103e9 100644
--- a/services/spar/src/Spar/Scim.hs
+++ b/services/spar/src/Spar/Scim.hs
@@ -103,7 +103,7 @@ import Wire.IdPConfigStore (IdPConfigStore)
 import Wire.Reporter (Reporter)
 import Wire.ScimExternalIdStore (ScimExternalIdStore)
 import Wire.ScimSubsystem
-import Wire.ScimUserTimesStore (ScimUserTimesStore)
+import Wire.ScimUserMetaStore (ScimUserMetaStore)
 import Wire.Sem.Logger (Logger)
 import Wire.Sem.Now (Now)
 import Wire.Sem.Random (Random)
@@ -127,7 +127,7 @@ apiScim ::
     Member BrigAPIAccess r,
     Member ScimSubsystem r,
     Member ScimExternalIdStore r,
-    Member ScimUserTimesStore r,
+    Member ScimUserMetaStore r,
     Member ScimTokenStore r,
     Member Reporter r,
     Member IdPConfigStore r,
diff --git a/services/spar/src/Spar/Scim/User.hs b/services/spar/src/Spar/Scim/User.hs
index 28c47848a91..95e040bc661 100644
--- a/services/spar/src/Spar/Scim/User.hs
+++ b/services/spar/src/Spar/Scim/User.hs
@@ -113,8 +113,8 @@ import Wire.IdPConfigStore (IdPConfigStore)
 import qualified Wire.IdPConfigStore as IdPConfigStore
 import Wire.ScimExternalIdStore (ScimExternalIdStore)
 import qualified Wire.ScimExternalIdStore as ScimExternalIdStore
-import Wire.ScimUserTimesStore (ScimUserTimesStore)
-import qualified Wire.ScimUserTimesStore as ScimUserTimesStore
+import Wire.ScimUserMetaStore (ScimUserMetaStore)
+import qualified Wire.ScimUserMetaStore as ScimUserMetaStore
 import Wire.Sem.Logger (Logger)
 import qualified Wire.Sem.Logger as Logger
 import Wire.Sem.Now (Now)
@@ -134,7 +134,7 @@ instance
     Member GalleyAPIAccess r,
     Member BrigAPIAccess r,
     Member ScimExternalIdStore r,
-    Member ScimUserTimesStore r,
+    Member ScimUserMetaStore r,
     Member IdPConfigStore r,
     Member SAMLUserStore r
   ) =>
@@ -252,6 +252,14 @@ tokenInfoToIdP :: (Member IdPConfigStore r) => ScimTokenInfo -> Scim.ScimHandler
 tokenInfoToIdP ScimTokenInfo {stiIdP} =
   mapM (lift . IdPConfigStore.getConfig) stiIdP
 
+-- | Maximum accepted length of the SCIM email @type@ sub-attribute.  The value
+-- is IdP-controlled and persisted verbatim in @scim_user_times.email_type@, so
+-- it must be bounded (mirrors the @richInfoLimit@ idea).  NB: validation runs
+-- in 'validateScimUser'', through which PATCHes also re-validate the
+-- synthesized old user — so a manual backfill must not write a longer value.
+maxScimEmailTypeLength :: Int
+maxScimEmailTypeLength = 64
+
 -- | Validate a handle (@userName@).
 validateHandle :: (Member (Error Scim.ScimError) r) => Text -> Sem r Handle
 validateHandle txt = case parseHandle txt of
@@ -303,7 +311,19 @@ validateScimUser' ::
   Sem r ST.ValidScimUser
 validateScimUser' errloc midp richInfoLimit user = do
   unless (isNothing $ Scim.password user) $ throw $ badRequest "Setting user passwords is not supported for security reasons."
-  veid <- mkValidScimId midp (Scim.externalId user) (Scim.Email.scimEmailsToEmailAddress $ Scim.emails user)
+  mSelectedEmail <-
+    either (throw . badRequest) pure $
+      Scim.Email.scimEmailsToEmail (Scim.emails user)
+  veid <- mkValidScimId midp (Scim.externalId user) (Scim.Email.emailToEmailAddress <$> mSelectedEmail)
+  let vsuEmailType = mSelectedEmail >>= Scim.Email.typ
+      vsuEmailPrimary = mSelectedEmail >>= fmap Scim.unScimBool . Scim.Email.primary
+  for_ vsuEmailType $ \ty ->
+    when (Text.length ty > maxScimEmailTypeLength) $
+      throw $
+        badRequest $
+          "SCIM email type is too long (max "
+            <> Text.pack (show maxScimEmailTypeLength)
+            <> " characters)."
   handl <- validateHandle . Text.toLower . Scim.userName $ user
   -- FUTUREWORK: 'Scim.userName' should be case insensitive; then the toLower here would
   -- be a little less brittle.
@@ -322,7 +342,7 @@ validateScimUser' errloc midp richInfoLimit user = do
   lang <- maybe (throw $ badRequest "Could not parse language. Expected format is ISO 639-1.") pure $ mapM parseLanguage $ Scim.preferredLanguage user
   mRole <- validateRole user
 
-  pure $ ST.ValidScimUser veid handl uname (maybeToList (justHere veid.validScimIdAuthInfo)) richInfo (maybe True Scim.unScimBool active) (flip Locale Nothing <$> lang) mRole
+  pure $ ST.ValidScimUser veid handl uname (justHere veid.validScimIdAuthInfo) vsuEmailType vsuEmailPrimary richInfo (maybe True Scim.unScimBool active) (flip Locale Nothing <$> lang) mRole
   where
     validRoleNames :: Text
     validRoleNames =
@@ -514,7 +534,7 @@ createValidScimUser ::
     Member GalleyAPIAccess r,
     Member BrigAPIAccess r,
     Member ScimExternalIdStore r,
-    Member ScimUserTimesStore r,
+    Member ScimUserMetaStore r,
     Member SAMLUserStore r,
     Member IdPConfigStore r
   ) =>
@@ -584,11 +604,11 @@ createValidScimUser tokeninfo@ScimTokenInfo {stiTeam} vsu@(ST.ValidScimUser {..}
         acc <-
           lift (BrigAPIAccess.getAccount Intra.WithPendingInvitations buid)
             >>= maybe (throwError $ Scim.serverError "Server error: user vanished") pure
-        synthesizeStoredUser acc externalId
+        synthesizeStoredUser acc externalId (Just (emailType, emailPrimary))
       lift $ Logger.debug ("createValidScimUser: spar says " <> show storedUser)
 
       -- {(arianvp): these two actions we probably want to make transactional.}
-      createValidScimUserSpar stiTeam buid storedUser externalId
+      createValidScimUserSpar stiTeam buid externalId
 
       -- If applicable, trigger email validation procedure on brig.
       -- FUTUREWORK: validate fallback emails?
@@ -617,23 +637,22 @@ createValidScimUser tokeninfo@ScimTokenInfo {stiTeam} vsu@(ST.ValidScimUser {..}
     externalIdTakenError :: Text -> Scim.ScimError
     externalIdTakenError msg = Scim.conflict {Scim.detail = Just ("ExternalId is already taken: " <> msg)}
 
--- | Store scim timestamps, saml credentials, scim externalId locally in spar.  Table
+-- | Store saml credentials, scim externalId locally in spar.  Table
 -- `spar.scim_external` gets an entry iff there is no `UserRef`: if there is, we don't do a
 -- lookup in that table either, but compute the `externalId` from the `UserRef`.
+--
+-- (Scim timestamps and email metadata are written by 'synthesizeStoredUser'.)
 createValidScimUserSpar ::
   forall m r.
   ( (m ~ Scim.ScimHandler (Sem r)),
     Member ScimExternalIdStore r,
-    Member ScimUserTimesStore r,
     Member SAMLUserStore r
   ) =>
   TeamId ->
   UserId ->
-  Scim.StoredUser ST.SparTag ->
   ST.ValidScimId ->
   m ()
-createValidScimUserSpar stiTeam uid storedUser veid = lift $ do
-  ScimUserTimesStore.write storedUser
+createValidScimUserSpar stiTeam uid veid = lift $ do
   ScimExternalIdStore.insert stiTeam veid.validScimIdExternal uid
   for_ (justThere veid.validScimIdAuthInfo) (`SAMLUserStore.insert` uid)
 
@@ -648,7 +667,7 @@ updateValidScimUser ::
     Member GalleyAPIAccess r,
     Member BrigAPIAccess r,
     Member ScimExternalIdStore r,
-    Member ScimUserTimesStore r,
+    Member ScimUserMetaStore r,
     Member IdPConfigStore r,
     Member SAMLUserStore r
   ) =>
@@ -691,6 +710,17 @@ updateValidScimUser tokinfo@ScimTokenInfo {stiTeam} uid nvsu =
             when (oldValidScimUser.externalId /= newValidScimUser.externalId) $
               updateVsuUref stiTeam uid (oldValidScimUser.externalId) (newValidScimUser.externalId)
 
+            -- An email-only change does not alter the externalId, so
+            -- 'updateVsuUref' (above, which only runs on an externalId change)
+            -- would not propagate the new email to Brig. Validate it here in
+            -- that case; when the externalId changes too, 'updateVsuUref' has
+            -- already validated the email, so we skip it here to avoid a
+            -- duplicate call.
+            when
+              ( oldValidScimUser.externalId == newValidScimUser.externalId
+                  && vsUserEmail oldValidScimUser /= vsUserEmail newValidScimUser
+              )
+              $ forM_ (vsUserEmail newValidScimUser) (Spar.App.validateEmail (Just stiTeam) uid)
             when (newValidScimUser.name /= oldValidScimUser.name) $
               BrigAPIAccess.setName uid (newValidScimUser.name)
 
@@ -713,7 +743,7 @@ updateValidScimUser tokinfo@ScimTokenInfo {stiTeam} uid nvsu =
                 let new = ST.scimActiveFlagToAccountStatus old (Just $ newValidScimUser.active)
                 when (new /= old) $ BrigAPIAccess.setStatus uid new
 
-            ScimUserTimesStore.write newScimStoredUser
+            ScimUserMetaStore.write newValidScimUser.emailType newValidScimUser.emailPrimary newScimStoredUser
           Scim.getUser tokinfo uid
 
 updateVsuUref ::
@@ -798,7 +828,7 @@ deleteScimUser ::
   ( Member (Logger (Msg -> Msg)) r,
     Member BrigAPIAccess r,
     Member ScimExternalIdStore r,
-    Member ScimUserTimesStore r,
+    Member ScimUserMetaStore r,
     Member SAMLUserStore r,
     Member IdPConfigStore r
   ) =>
@@ -847,7 +877,7 @@ deleteScimUser tokeninfo@ScimTokenInfo {stiTeam, stiIdP} uid =
       ( Member IdPConfigStore r,
         Member SAMLUserStore r,
         Member ScimExternalIdStore r,
-        Member ScimUserTimesStore r
+        Member ScimUserMetaStore r
       ) =>
       User ->
       Scim.ScimHandler (Sem r) ()
@@ -867,7 +897,7 @@ deleteScimUser tokeninfo@ScimTokenInfo {stiTeam, stiIdP} uid =
         Right veid -> lift $ do
           for_ (justThere veid.validScimIdAuthInfo) (SAMLUserStore.delete uid)
           ScimExternalIdStore.delete stiTeam veid.validScimIdExternal
-      lift $ ScimUserTimesStore.delete uid
+      lift $ ScimUserMetaStore.delete uid
 
 ----------------------------------------------------------------------------
 -- Utilities
@@ -966,6 +996,14 @@ assertHandleNotUsedElsewhere uid hndl = do
 -- | Helper function that translates a given brig user into a 'Scim.StoredUser', with some
 -- effects like updating the 'ManagedBy' field in brig and storing creation and update time
 -- stamps.
+--
+-- @mEmailMeta@: @'Just' (ty, pr)@ forces the email metadata (used by user creation, where
+-- the store row does not exist yet); 'Nothing' uses the stored metadata.
+--
+-- NB: callers MUST only pass 'Just' when the store row is known to be absent:
+-- metadata is persisted only when the row is written (i.e. it did not exist),
+-- so forcing metadata on an existing row would echo values that were never
+-- stored.
 synthesizeStoredUser ::
   forall r.
   ( Member (Input Opts) r,
@@ -973,12 +1011,13 @@ synthesizeStoredUser ::
     Member (Logger (Msg -> Msg)) r,
     Member BrigAPIAccess r,
     Member GalleyAPIAccess r,
-    Member ScimUserTimesStore r
+    Member ScimUserMetaStore r
   ) =>
   User ->
   ST.ValidScimId ->
+  Maybe (Maybe Text, Maybe Bool) ->
   Scim.ScimHandler (Sem r) (Scim.StoredUser ST.SparTag)
-synthesizeStoredUser acc veid =
+synthesizeStoredUser acc veid mEmailMeta =
   logScim
     ( logFunction "Spar.Scim.User.synthesizeStoredUser"
         . logUser (userId acc)
@@ -991,18 +1030,25 @@ synthesizeStoredUser acc veid =
       let uid = userId acc
           accStatus = acc.userStatus
 
-      let readState :: Sem r (RI.RichInfo, Maybe (UTCTimeMillis, UTCTimeMillis), URIBS.URI, Role)
+      let readState :: Sem r (RI.RichInfo, Maybe ScimUserMetaStore.ScimUserMeta, URIBS.URI, Role)
           readState =
             (,,,)
               <$> BrigAPIAccess.getRichInfo uid
-              <*> ScimUserTimesStore.read uid
+              <*> ScimUserMetaStore.read uid
               <*> inputs scimBaseUri
               <*> getRole
 
-      let writeState :: Maybe (UTCTimeMillis, UTCTimeMillis) -> ManagedBy -> RI.RichInfo -> Scim.StoredUser ST.SparTag -> Sem r ()
-          writeState oldAccessTimes oldManagedBy oldRichInfo storedUser = do
-            when (isNothing oldAccessTimes) $
-              ScimUserTimesStore.write storedUser
+      (richInfo, accessTimes, baseuri, role) <- lift readState
+      now <- toUTCTimeMillis <$> lift Now.get
+      let (createdAt, lastUpdatedAt, storedEmailType, storedEmailPrimary) = case accessTimes of
+            Just t -> (t.scimUserMetaCreated, t.scimUserMetaLastUpdated, t.scimUserMetaEmailType, t.scimUserMetaEmailPrimary)
+            Nothing -> (now, now, Nothing, Nothing)
+          (emailType, emailPrimary) = fromMaybe (storedEmailType, storedEmailPrimary) mEmailMeta
+
+      let writeState :: ManagedBy -> RI.RichInfo -> Scim.StoredUser ST.SparTag -> Sem r ()
+          writeState oldManagedBy oldRichInfo storedUser = do
+            when (isNothing accessTimes) $
+              ScimUserMetaStore.write emailType emailPrimary storedUser
             when (oldManagedBy /= ManagedByScim) $ do
               BrigAPIAccess.setManagedBy uid ManagedByScim
               -- Invalidate any pending email-address update: a SCIM-managed user's
@@ -1013,15 +1059,10 @@ synthesizeStoredUser acc veid =
             when (oldRichInfo /= newRichInfo) $
               BrigAPIAccess.setRichInfo uid newRichInfo
 
-      (richInfo, accessTimes, baseuri, role) <- lift readState
-      now <- toUTCTimeMillis <$> lift Now.get
-      let (createdAt, lastUpdatedAt) = fromMaybe (now, now) accessTimes
-
       handle <- lift $ Intra.giveDefaultHandle acc
 
       let emails =
-            maybeToList $
-              acc.userEmailUnvalidated <|> (emailIdentity =<< userIdentity acc) <|> justHere veid.validScimIdAuthInfo
+            acc.userEmailUnvalidated <|> (emailIdentity =<< userIdentity acc) <|> justHere veid.validScimIdAuthInfo
 
       storedUser <-
         synthesizeStoredUser'
@@ -1029,6 +1070,8 @@ synthesizeStoredUser acc veid =
           veid
           acc.userDisplayName
           emails
+          emailType
+          emailPrimary
           handle
           richInfo
           accStatus
@@ -1037,7 +1080,7 @@ synthesizeStoredUser acc veid =
           baseuri
           acc.userLocale
           (Just role)
-      lift $ writeState accessTimes acc.userManagedBy richInfo storedUser
+      lift $ writeState acc.userManagedBy richInfo storedUser
       pure storedUser
   where
     getRole :: Sem r Role
@@ -1050,7 +1093,9 @@ synthesizeStoredUser' ::
   UserId ->
   ST.ValidScimId ->
   Name ->
-  [EmailAddress] ->
+  Maybe EmailAddress ->
+  Maybe Text ->
+  Maybe Bool ->
   Handle ->
   RI.RichInfo ->
   AccountStatus ->
@@ -1060,7 +1105,7 @@ synthesizeStoredUser' ::
   Locale ->
   Maybe Role ->
   m (Scim.StoredUser ST.SparTag)
-synthesizeStoredUser' uid veid dname emails handle richInfo accStatus createdAt lastUpdatedAt baseuri locale mbRole = do
+synthesizeStoredUser' uid veid dname emails emailType emailPrimary handle richInfo accStatus createdAt lastUpdatedAt baseuri locale mbRole = do
   let scimUser :: Scim.User ST.SparTag
       scimUser =
         synthesizeScimUser
@@ -1070,6 +1115,8 @@ synthesizeStoredUser' uid veid dname emails handle richInfo accStatus createdAt
                                     checker to make sure this exists, so we add it here
                                     redundantly, without the 'Maybe'. -},
               ST.emails = emails,
+              ST.emailType = emailType,
+              ST.emailPrimary = emailPrimary,
               ST.name = dname,
               ST.richInfo = richInfo,
               ST.active = ST.scimActiveFlagFromAccountStatus accStatus,
@@ -1104,7 +1151,16 @@ synthesizeScimUser info =
                   ]
               )
               (info.role),
-          Scim.emails = (\e -> Scim.Email.Email Nothing (Scim.Email.EmailAddress e) Nothing) <$> info.emails
+          -- The type/primary metadata of the stored email entry are persisted in
+          -- @spar.scim_user_times@ (see "Wire.ScimUserMetaStore") and echoed exactly
+          -- as the IdP sent them; nothing is synthesized.  Users provisioned before
+          -- the metadata existed (or by providers that send none) echo neither
+          -- field, so value-path filters like @emails[type eq \"work\"]@ only match
+          -- when that type was actually supplied — by design (strict round-trip).
+          Scim.emails =
+            maybeToList $
+              (\e -> Scim.Email.Email info.emailType (Scim.Email.EmailAddress e) (Scim.ScimBool <$> info.emailPrimary))
+                <$> info.emails
         }
 
 -- TODO: now write a test, either in /integration or in spar, whichever is easier.  (spar)
@@ -1117,7 +1173,7 @@ getUserById ::
     Member Now r,
     Member SAMLUserStore r,
     Member ScimExternalIdStore r,
-    Member ScimUserTimesStore r
+    Member ScimUserMetaStore r
   ) =>
   Maybe IdP ->
   TeamId ->
@@ -1129,12 +1185,12 @@ getUserById midp stiTeam uid = do
       mbNewVeid = Intra.newVeidFromBrigUser brigUser ((^. SAML.idpMetadata . SAML.edIssuer) <$> midp)
   case mbNewVeid of
     Right veid | userTeam brigUser == Just stiTeam -> lift $ do
-      storedUser :: Scim.StoredUser ST.SparTag <- synthesizeStoredUser brigUser veid
+      storedUser :: Scim.StoredUser ST.SparTag <- synthesizeStoredUser brigUser veid Nothing
       -- if we get a user from brig that hasn't been touched by scim yet, we call this
       -- function to move it under scim control.
       assertExternalIdNotUsedElsewhere stiTeam veid uid
       handleVeidChange brigUser mbOldVeid veid
-      createValidScimUserSpar stiTeam uid storedUser veid
+      createValidScimUserSpar stiTeam uid veid
       pure storedUser
     _ -> Applicative.empty
   where
@@ -1163,7 +1219,7 @@ scimFindUserByHandle ::
     Member Now r,
     Member SAMLUserStore r,
     Member ScimExternalIdStore r,
-    Member ScimUserTimesStore r
+    Member ScimUserMetaStore r
   ) =>
   Maybe IdP ->
   TeamId ->
@@ -1189,7 +1245,7 @@ scimFindUserByExternalId ::
     Member Now r,
     Member SAMLUserStore r,
     Member ScimExternalIdStore r,
-    Member ScimUserTimesStore r
+    Member ScimUserMetaStore r
   ) =>
   Maybe IdP ->
   TeamId ->
diff --git a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs
index 211e434e3bd..46307a710d3 100644
--- a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs
+++ b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs
@@ -93,7 +93,7 @@ import qualified Wire.API.User.Scim as Spar.Types
 import qualified Wire.API.User.Search as Search
 import qualified Wire.BrigAPIAccess as BrigAPIAccess
 import qualified Wire.ScimExternalIdStore as ScimExternalIdStore
-import qualified Wire.ScimUserTimesStore as ScimUserTimesStore
+import qualified Wire.ScimUserMetaStore as ScimUserMetaStore
 
 -- | Tests for @\/scim\/v2\/Users@.
 spec :: SpecWith TestEnv
@@ -2062,7 +2062,6 @@ specPatchUser = do
             PatchOp.Remove
             (Just (PatchOp.NormalPath (Filter.topLevelAttrPath name)))
             Nothing
-
     it "doing nothing doesn't change the user" $ do
       (tok, _) <- registerIdPAndScimToken
       user <- randomScimUser
@@ -2344,10 +2343,10 @@ specDeleteUser = do
         aFewTimes (runSpar $ BrigAPIAccess.getAccount Intra.WithPendingInvitations uid) isNothing
       samlUser :: Maybe UserId <-
         aFewTimes (getUserIdViaRef' uref) isNothing
-      scimUser <-
-        aFewTimes (runSpar $ ScimUserTimesStore.read uid) isNothing
+      scimUserDeleted <-
+        aFewTimes (runSpar $ ScimUserMetaStore.read uid) isNothing
       liftIO $
-        (brigUser, samlUser, scimUser)
+        (brigUser, samlUser, scimUserDeleted)
           `shouldBe` (Nothing, Nothing, Nothing)
     it "should respond with 204 on deletion (also indempotently)" $ do
       (tok, _) <- registerIdPAndScimToken
diff --git a/services/spar/test/Test/Spar/Scim/UserSpec.hs b/services/spar/test/Test/Spar/Scim/UserSpec.hs
index fb733fb7253..c7c9678d906 100644
--- a/services/spar/test/Test/Spar/Scim/UserSpec.hs
+++ b/services/spar/test/Test/Spar/Scim/UserSpec.hs
@@ -40,8 +40,8 @@ import Wire.IdPConfigStore.Mem (idPToMem)
 import Wire.IdPConfigStore.Orphans ()
 import qualified Wire.ScimExternalIdStore as ScimExternalIdStore
 import Wire.ScimExternalIdStore.Mem (scimExternalIdStoreToMem)
-import Wire.ScimUserTimesStore
-import Wire.ScimUserTimesStore.Mem (scimUserTimesStoreToMem)
+import Wire.ScimUserMetaStore
+import Wire.ScimUserMetaStore.Mem (scimUserMetaStoreToMem)
 import Wire.Sem.Logger.TinyLog (discardTinyLogs)
 
 spec :: Spec
@@ -85,7 +85,7 @@ deleteUserAndAssertDeletionInSpar ::
       '[ Logger (Msg -> Msg),
          BrigAPIAccess,
          ScimExternalIdStore.ScimExternalIdStore,
-         ScimUserTimesStore,
+         ScimUserMetaStore,
          SAMLUserStore,
          IdPConfigStore,
          Embed IO
@@ -108,7 +108,7 @@ deleteUserAndAssertDeletionInSpar acc tokenInfo = do
 type EffsWithoutBrigAPIAccess =
   '[ IdPConfigStore,
      SAMLUserStore,
-     ScimUserTimesStore,
+     ScimUserMetaStore,
      ScimExternalIdStore.ScimExternalIdStore,
      Logger (Msg -> Msg),
      Embed IO,
@@ -126,7 +126,7 @@ interpretWithBrigAPIAccessMock mock =
     . embedToFinal @IO
     . discardTinyLogs
     . ignoringState scimExternalIdStoreToMem
-    . ignoringState scimUserTimesStoreToMem
+    . ignoringState scimUserMetaStoreToMem
     . ignoringState samlUserStoreToMem
     . ignoringState idPToMem
     . mock

From 6c1f86b6d668aab3ca2f7b56d6e1249edf471103 Mon Sep 17 00:00:00 2001
From: Gautier DI FOLCO 
Date: Fri, 21 Aug 2026 01:35:28 +0200
Subject: [PATCH 102/113] WPB-28163: move V17 endpoints to V18 (#5468)

---
 changelog.d/1-api-changes/WPB-27393           |  1 -
 .../WPB-28163-move-v17-endpoints-to-v18       |  1 +
 changelog.d/99-pending/WPB-27393              |  1 +
 changelog.d/mk-changelog.sh                   |  4 ++++
 changelog.d/mk-cleanup.sh                     |  6 ++++-
 .../src/developer/reference/config-options.md |  2 +-
 integration/test/Test/AdminlessGroups.hs      | 16 +++++++-------
 .../FeatureFlags/PreventAdminlessGroups.hs    |  6 ++---
 integration/test/Test/Spar.hs                 |  2 +-
 integration/test/Test/Swagger.hs              |  2 +-
 integration/test/Test/Version.hs              |  6 ++---
 libs/wire-api/src/Wire/API/Routes/Features.hs |  6 ++---
 .../src/Wire/API/Routes/Public/Brig.hs        |  4 ++--
 .../API/Routes/Public/Galley/Conversation.hs  |  4 ++--
 .../Wire/API/Routes/Public/Galley/Feature.hs  |  4 ++--
 .../src/Wire/API/Routes/Public/Swagger.hs     | 22 +++++++++----------
 libs/wire-api/src/Wire/API/Routes/Version.hs  |  6 ++++-
 libs/wire-api/src/Wire/API/Team/Feature.hs    |  4 ++--
 services/brig/docs/swagger-v17.json           |  1 +
 services/brig/src/Brig/API/Public.hs          |  3 ++-
 .../galley/src/Galley/API/Public/Feature.hs   |  2 +-
 21 files changed, 59 insertions(+), 44 deletions(-)
 delete mode 100644 changelog.d/1-api-changes/WPB-27393
 create mode 100644 changelog.d/5-internal/WPB-28163-move-v17-endpoints-to-v18
 create mode 100644 changelog.d/99-pending/WPB-27393
 create mode 100644 services/brig/docs/swagger-v17.json

diff --git a/changelog.d/1-api-changes/WPB-27393 b/changelog.d/1-api-changes/WPB-27393
deleted file mode 100644
index 7deb183d310..00000000000
--- a/changelog.d/1-api-changes/WPB-27393
+++ /dev/null
@@ -1 +0,0 @@
-V17 `PUT /conversations/{domain}/{conversation}/members` rejects replacements that would leave a regular group without an admin; V16 retains the legacy autopromotion behavior.
diff --git a/changelog.d/5-internal/WPB-28163-move-v17-endpoints-to-v18 b/changelog.d/5-internal/WPB-28163-move-v17-endpoints-to-v18
new file mode 100644
index 00000000000..91dc14e9ff5
--- /dev/null
+++ b/changelog.d/5-internal/WPB-28163-move-v17-endpoints-to-v18
@@ -0,0 +1 @@
+Moved the following endpoints from development version V17 to the new development version V18: `PUT /conversations/:domain/:cnv/members` (rejection of replacements that would leave a group adminless), `PUT /teams/:tid/features/preventAdminlessGroups` (duration-string request body), and `POST /register` (403 for SCIM-managed users changing their name). V17 behaves like V16 for these endpoints. API version V18 was created as a development version; V17 remains a development version until finalized. The changelog entry for the moved members endpoint is parked in `changelog.d/99-pending/`, which `mk-changelog.sh` and `mk-cleanup.sh` now skip.
diff --git a/changelog.d/99-pending/WPB-27393 b/changelog.d/99-pending/WPB-27393
new file mode 100644
index 00000000000..df86814ba2d
--- /dev/null
+++ b/changelog.d/99-pending/WPB-27393
@@ -0,0 +1 @@
+V18 `PUT /conversations/{domain}/{conversation}/members` rejects replacements that would leave a regular group without an admin; V17 and older retain the legacy autopromotion behavior. (#5387)
diff --git a/changelog.d/mk-changelog.sh b/changelog.d/mk-changelog.sh
index aa8cc3fd3bd..167abeb7f70 100755
--- a/changelog.d/mk-changelog.sh
+++ b/changelog.d/mk-changelog.sh
@@ -15,6 +15,10 @@ get_pr_number() {
 
 for d in "$DIR"/*; do
     if [[ ! -d "$d" ]]; then continue; fi
+    # 99-pending: entries deferred out of the upcoming release. Since renaming a
+    # file breaks get_pr_number (git log does not follow renames), each parked
+    # entry must bake its PR number into its text, e.g. " (#1234)".
+    if [[ "$(basename "$d")" == "99-pending" ]]; then continue; fi
 
     entries=("$d"/*[^~])
 
diff --git a/changelog.d/mk-cleanup.sh b/changelog.d/mk-cleanup.sh
index b483a6e6041..36c5a093a22 100755
--- a/changelog.d/mk-cleanup.sh
+++ b/changelog.d/mk-cleanup.sh
@@ -5,5 +5,9 @@ shopt -s nullglob
 
 DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 
-rm -f "$DIR"/*/*
+for d in "$DIR"/*; do
+    [[ -d "$d" ]] || continue
+    if [[ "$(basename "$d")" == "99-pending" ]]; then continue; fi
+    rm -f "$d"/*
+done
 git add "$DIR"
diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md
index 90dd5ad7fd8..cf86ee7681c 100644
--- a/docs/src/developer/reference/config-options.md
+++ b/docs/src/developer/reference/config-options.md
@@ -425,7 +425,7 @@ Feature responses, including `GET /feature-configs`, `GET /teams/:tid/features`,
 
 From a client's perspective, API versioning works like this:
 
-- API version V17 and newer should send the duration fields to `PUT /teams/:tid/features/preventAdminlessGroups`.
+- API version V18 and newer should send the duration fields to `PUT /teams/:tid/features/preventAdminlessGroups`.
 - Feature responses include the duration fields for clients to read.
 
 The lock status for individual teams can be changed via the internal API (`PUT /i/teams/:tid/features/preventAdminlessGroups/(un)?locked`).
diff --git a/integration/test/Test/AdminlessGroups.hs b/integration/test/Test/AdminlessGroups.hs
index d6c18413f3a..139d5439357 100644
--- a/integration/test/Test/AdminlessGroups.hs
+++ b/integration/test/Test/AdminlessGroups.hs
@@ -201,9 +201,9 @@ testAdminlessReplaceMembers = do
       members <- resp.json %. "members.others" & asList
       shouldBeEmpty members
 
-  testVersion 17 $ \alice bob conv version -> do
+  testVersion 18 $ \alice bob conv version -> do
     bobId <- bob %. "qualified_id"
-    -- V17 rejects a replacement that would remove the last admin while leaving
+    -- V18 rejects a replacement that would remove the last admin while leaving
     -- only eligible non-admin members.
     bindResponse (replaceMembers alice conv def {users = [bobId], version = Just version}) $ \resp -> do
       resp.status `shouldMatchInt` 403
@@ -234,9 +234,9 @@ testAdminlessReplaceMembersAddsAdmin = do
   bobId <- bob %. "qualified_id"
   charlieId <- charlie %. "qualified_id"
 
-  -- V17 accepts replacing the existing admin when the same request adds a new
+  -- V18 accepts replacing the existing admin when the same request adds a new
   -- admin, because the resulting conversation is not adminless.
-  bindResponse (replaceMembers alice conv def {users = [bobId, charlieId], role = Just "wire_admin", version = Just 17}) $ \resp -> do
+  bindResponse (replaceMembers alice conv def {users = [bobId, charlieId], role = Just "wire_admin", version = Just 18}) $ \resp -> do
     resp.status `shouldMatchInt` 200
 
   bindResponse (getConversation charlie conv) $ \resp -> do
@@ -253,10 +253,10 @@ testAdminlessReplaceMembersAddsEligibleMember = do
   conv <- postConversation alice (defProteus {team = Just tid, qualifiedUsers = [], newUsersRole = "wire_member"}) >>= getJSON 201
   bobId <- bob %. "qualified_id"
 
-  -- V17 rejects a replacement that removes the only admin even when the
+  -- V18 rejects a replacement that removes the only admin even when the
   -- eligible member is added by the same request.
   bindResponse
-    (replaceMembers alice conv def {users = [bobId], role = Just "wire_member", version = Just 17})
+    (replaceMembers alice conv def {users = [bobId], role = Just "wire_member", version = Just 18})
     $ \resp -> do
       resp.status `shouldMatchInt` 403
       resp.json %. "label" `shouldMatch` "adminless-conversation"
@@ -315,7 +315,7 @@ testAdminlessSetupMemberUpdateAfterAdminLeaves = do
     resp.status `shouldMatchInt` 200
 
   withWebSockets [bob] $ \[wsBob] -> do
-    setTeamFeatureConfigVersioned (ExplicitVersion 17) alice tid "preventAdminlessGroups" (mkAdminlessFeature "enabled" "10s" []) >>= assertSuccess
+    setTeamFeatureConfigVersioned (ExplicitVersion 18) alice tid "preventAdminlessGroups" (mkAdminlessFeature "enabled" "10s" []) >>= assertSuccess
 
     notif <- awaitMatchFor 20 isMemberUpdateNotif wsBob
     notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv
@@ -350,7 +350,7 @@ testAdminlessSetupDeletesWithOriginAndRemoteMembers = do
     conversationIds `shouldContain` [convQid]
 
   withWebSockets [remoteUser] $ \[wsRemoteUser] -> do
-    setTeamFeatureConfigVersioned (ExplicitVersion 17) alice tid "preventAdminlessGroups" (mkAdminlessFeature "enabled" "1s" []) >>= assertSuccess
+    setTeamFeatureConfigVersioned (ExplicitVersion 18) alice tid "preventAdminlessGroups" (mkAdminlessFeature "enabled" "1s" []) >>= assertSuccess
 
     deleteNotif <- awaitMatchFor 20 isConvDeleteNotif wsRemoteUser
     deleteNotif %. "payload.0.qualified_from" `shouldMatch` objQidObject alice
diff --git a/integration/test/Test/FeatureFlags/PreventAdminlessGroups.hs b/integration/test/Test/FeatureFlags/PreventAdminlessGroups.hs
index 72febf3f84e..9bdd9ed2a8a 100644
--- a/integration/test/Test/FeatureFlags/PreventAdminlessGroups.hs
+++ b/integration/test/Test/FeatureFlags/PreventAdminlessGroups.hs
@@ -80,12 +80,12 @@ testPreventAdminlessGroupsPutV16AcceptsLegacyTimeoutFields = do
       resp.json `shouldMatch` canonicalPreventAdminlessGroupsFeature
   checkFeature "preventAdminlessGroups" owner tid canonicalPreventAdminlessGroupsFeature
 
-testPreventAdminlessGroupsPutV17AcceptsDurationTimeoutFields :: (HasCallStack) => App ()
-testPreventAdminlessGroupsPutV17AcceptsDurationTimeoutFields = do
+testPreventAdminlessGroupsPutV18AcceptsDurationTimeoutFields :: (HasCallStack) => App ()
+testPreventAdminlessGroupsPutV18AcceptsDurationTimeoutFields = do
   (owner, tid, _) <- createTeam OwnDomain 0
   bindResponse
     ( Public.setTeamFeatureConfigVersioned
-        (ExplicitVersion 17)
+        (ExplicitVersion 18)
         owner
         tid
         "preventAdminlessGroups"
diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs
index 7506c5f3f34..7d45322967a 100644
--- a/integration/test/Test/Spar.hs
+++ b/integration/test/Test/Spar.hs
@@ -1879,7 +1879,7 @@ testScimUserIsNotAllowedToChangeNameOnRegistering = do
     resp.json %. "name" `shouldMatch` scimUserDisplayName
 
   let newProfilename = "Takemiya Masaki"
-  registerUserWith OwnDomain email code newProfilename `bindResponse` \resp -> do
+  registerUserWithVersioned (ExplicitVersion 18) OwnDomain email code newProfilename `bindResponse` \resp -> do
     resp.status `shouldMatchInt` 403
     resp.json %. "label" `shouldMatch` "managed-by-scim"
 
diff --git a/integration/test/Test/Swagger.hs b/integration/test/Test/Swagger.hs
index 8152f9007c8..c150b0d62f0 100644
--- a/integration/test/Test/Swagger.hs
+++ b/integration/test/Test/Swagger.hs
@@ -30,7 +30,7 @@ import Testlib.Prelude
 import UnliftIO.Temporary
 
 existingVersions :: Set Int
-existingVersions = Set.fromList [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]
+existingVersions = Set.fromList [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]
 
 internalApis :: Set String
 internalApis = Set.fromList ["brig", "cannon", "cargohold", "cannon", "spar"]
diff --git a/integration/test/Test/Version.hs b/integration/test/Test/Version.hs
index 12346b09b34..c5f2ec922d1 100644
--- a/integration/test/Test/Version.hs
+++ b/integration/test/Test/Version.hs
@@ -60,9 +60,9 @@ testVersion (Versioned' v) = withModifiedBackend
       domain <- resp.json %. "domain" & asString
       federation <- resp.json %. "federation" & asBool
 
-      -- currently there is one development version
-      -- it is however theoretically possible to have multiple development versions
-      length dev `shouldMatchInt` 1
+      -- during a version bump, there are two development versions until the
+      -- older one is released (i.e. moved to supported and frozen)
+      dev `shouldMatchSet` [17, 18 :: Int]
       domain `shouldMatch` dom
       federation `shouldMatch` True
 
diff --git a/libs/wire-api/src/Wire/API/Routes/Features.hs b/libs/wire-api/src/Wire/API/Routes/Features.hs
index 3b1401d26b0..fc8d7eb43b3 100644
--- a/libs/wire-api/src/Wire/API/Routes/Features.hs
+++ b/libs/wire-api/src/Wire/API/Routes/Features.hs
@@ -19,7 +19,7 @@ module Wire.API.Routes.Features where
 
 import Wire.API.Conversation.Role
 import Wire.API.Error.Galley
-import Wire.API.Routes.Version (Version (V17))
+import Wire.API.Routes.Version (Version (V18))
 import Wire.API.Team.Feature
 
 type family FeatureErrors cfg where
@@ -42,6 +42,6 @@ type family FeatureAPIDesc cfg where
   FeatureAPIDesc _ = ""
 
 type family VersionedFeatureAPIDesc v cfg where
-  VersionedFeatureAPIDesc V17 PreventAdminlessGroupsConfig =
-    "

For API version 17, use duration strings for the timeout fields. The request body must have the following shape:

{\n  "config": {\n    "deletionTimeoutDuration": "7d",\n    "promotionStrategy": "alphabetical",\n    "reminderTimeoutDurations": [\n      "2d",\n      "4d",\n      "6d"\n    ]\n  },\n  "status": "enabled"\n}

Older API versions use the legacy numeric fields deletionTimeout and reminderTimeouts.

" + VersionedFeatureAPIDesc V18 PreventAdminlessGroupsConfig = + "

For API version 18, use duration strings for the timeout fields. The request body must have the following shape:

{\n  "config": {\n    "deletionTimeoutDuration": "7d",\n    "promotionStrategy": "alphabetical",\n    "reminderTimeoutDurations": [\n      "2d",\n      "4d",\n      "6d"\n    ]\n  },\n  "status": "enabled"\n}

Older API versions use the legacy numeric fields deletionTimeout and reminderTimeouts.

" VersionedFeatureAPIDesc _ cfg = FeatureAPIDesc cfg diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs index cf7012ccba7..fd3c3901731 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -669,7 +669,7 @@ type AccountAPI = Named "register@v16" ( Summary "Register a new user." - :> Until 'V17 + :> Until 'V18 :> Description "If the environment where the registration takes \ \place is private and a registered email address \ @@ -682,7 +682,7 @@ type AccountAPI = :<|> Named "register" ( Summary "Register a new user." - :> From 'V17 + :> From 'V18 :> Description "If the environment where the registration takes \ \place is private and a registered email address \ diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs index fc5ac034f01..d7ef224b522 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs @@ -965,7 +965,7 @@ type ConversationAPI = \The roles of already existing members will not be changed \ \even if these members are included in the request body and their role differs from the role provided in this request." :> From 'V13 - :> Until 'V17 + :> Until 'V18 :> CanThrow ('ActionDenied 'AddConversationMember) :> CanThrow ('ActionDenied 'RemoveConversationMember) :> CanThrow ('ActionDenied 'LeaveConversation) @@ -996,7 +996,7 @@ type ConversationAPI = \The given role in the request body will be applied to all added members. \ \The roles of already existing members will not be changed \ \even if these members are included in the request body and their role differs from the role provided in this request." - :> From 'V17 + :> From 'V18 :> CanThrow ('ActionDenied 'AddConversationMember) :> CanThrow ('ActionDenied 'RemoveConversationMember) :> CanThrow ('ActionDenied 'LeaveConversation) diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs index 47d3524bf0c..ea18bc9a151 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs @@ -70,8 +70,8 @@ type FeatureAPI = :<|> FeatureAPIGet DomainRegistrationConfig :<|> FeatureAPIGetPut ChannelsConfig :<|> FeatureAPIGet PreventAdminlessGroupsConfig - :<|> Until 'V17 ::> VersionedFeatureAPIPut "put-PreventAdminlessGroupsConfig@v16" V16 PreventAdminlessGroupsConfig - :<|> From 'V17 ::> VersionedFeatureAPIPut "put-PreventAdminlessGroupsConfig@v17" V17 PreventAdminlessGroupsConfig + :<|> Until 'V18 ::> VersionedFeatureAPIPut "put-PreventAdminlessGroupsConfig@v16" V16 PreventAdminlessGroupsConfig + :<|> From 'V18 ::> VersionedFeatureAPIPut "put-PreventAdminlessGroupsConfig@v18" V18 PreventAdminlessGroupsConfig :<|> FeatureAPIGet CellsConfig :<|> Until 'V14 ::> VersionedFeatureAPIPut "put-CellsConfig@v13" V13 CellsConfig :<|> From 'V14 ::> FeatureAPIPut CellsConfig diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs b/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs index 31bcc202fd4..63791ced758 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs @@ -44,11 +44,11 @@ import Wire.API.Routes.Version import Wire.API.SwaggerHelper (cleanupSwagger) -- | The version 'devVersionSwagger' describes. Must stay in sync with the type --- level @\'V17@ below; there is no way to tie the two together, since the +-- level @\'V18@ below; there is no way to tie the two together, since the -- 'S.OpenApi' has to be assembled at a statically known version. devVersion :: Version devVersion = - if maxBound == V17 + if maxBound == V18 then maxBound else -- if you get this error, you also need to update the version literals below. @@ -60,15 +60,15 @@ devVersion = -- @info.description@, so setting it afterwards is equivalent. devVersionSwagger :: S.OpenApi devVersionSwagger = - ( serviceSwagger @VersionAPITag @'V17 - <> serviceSwagger @BrigAPITag @'V17 - <> serviceSwagger @GalleyAPITag @'V17 - <> serviceSwagger @SparAPITag @'V17 - <> serviceSwagger @CargoholdAPITag @'V17 - <> serviceSwagger @CannonAPITag @'V17 - <> serviceSwagger @GundeckAPITag @'V17 - <> serviceSwagger @ProxyAPITag @'V17 - <> serviceSwagger @OAuthAPITag @'V17 + ( serviceSwagger @VersionAPITag @'V18 + <> serviceSwagger @BrigAPITag @'V18 + <> serviceSwagger @GalleyAPITag @'V18 + <> serviceSwagger @SparAPITag @'V18 + <> serviceSwagger @CargoholdAPITag @'V18 + <> serviceSwagger @CannonAPITag @'V18 + <> serviceSwagger @GundeckAPITag @'V18 + <> serviceSwagger @ProxyAPITag @'V18 + <> serviceSwagger @OAuthAPITag @'V18 ) & S.info . S.title .~ "Wire-Server API" & S.servers .~ [S.Server ("/" <> toUrlPiece devVersion) Nothing mempty] diff --git a/libs/wire-api/src/Wire/API/Routes/Version.hs b/libs/wire-api/src/Wire/API/Routes/Version.hs index 330e6f00bb3..df2b9b3aac5 100644 --- a/libs/wire-api/src/Wire/API/Routes/Version.hs +++ b/libs/wire-api/src/Wire/API/Routes/Version.hs @@ -103,7 +103,7 @@ import Wire.Arbitrary (Arbitrary, GenericUniform (GenericUniform)) -- and 'developmentVersions' stay in sync; everything else here should keep working without -- change. See also documentation in the *docs* directory. -- https://docs.wire.com/developer/developer/api-versioning.html#version-bump-checklist -data Version = V0 | V1 | V2 | V3 | V4 | V5 | V6 | V7 | V8 | V9 | V10 | V11 | V12 | V13 | V14 | V15 | V16 | V17 +data Version = V0 | V1 | V2 | V3 | V4 | V5 | V6 | V7 | V8 | V9 | V10 | V11 | V12 | V13 | V14 | V15 | V16 | V17 | V18 deriving stock (Eq, Ord, Bounded, Enum, Show, Generic) deriving (FromJSON, ToJSON) via (Schema Version) deriving (Arbitrary) via (GenericUniform Version) @@ -142,6 +142,8 @@ instance RenderableSymbol V16 where renderSymbol = "V16" instance RenderableSymbol V17 where renderSymbol = "V17" +instance RenderableSymbol V18 where renderSymbol = "V18" + -- | Manual enumeration of version integrals (the `` in the constructor `V`). -- -- This is not the same as 'fromEnum': we will remove unsupported versions in the future, @@ -167,6 +169,7 @@ versionInt V14 = 14 versionInt V15 = 15 versionInt V16 = 16 versionInt V17 = 17 +versionInt V18 = 18 supportedVersions :: [Version] supportedVersions = [minBound .. maxBound] @@ -292,6 +295,7 @@ isDevelopmentVersion V14 = False isDevelopmentVersion V15 = False isDevelopmentVersion V16 = False isDevelopmentVersion V17 = True +isDevelopmentVersion V18 = True developmentVersions :: [Version] developmentVersions = filter isDevelopmentVersion supportedVersions diff --git a/libs/wire-api/src/Wire/API/Team/Feature.hs b/libs/wire-api/src/Wire/API/Team/Feature.hs index 2e0d6ee8971..28563253014 100644 --- a/libs/wire-api/src/Wire/API/Team/Feature.hs +++ b/libs/wire-api/src/Wire/API/Team/Feature.hs @@ -1436,13 +1436,13 @@ instance ToSchema (Versioned V16 PreventAdminlessGroupsConfig) where instance ToObjectSchema (Versioned V16 PreventAdminlessGroupsConfig) where objectSchema = field "config" schema -instance ToSchema (Versioned V17 PreventAdminlessGroupsConfig) where +instance ToSchema (Versioned V18 PreventAdminlessGroupsConfig) where schema = object $ Versioned <$> unVersioned .= durationPreventAdminlessGroupsConfigObjectSchema -instance ToObjectSchema (Versioned V17 PreventAdminlessGroupsConfig) where +instance ToObjectSchema (Versioned V18 PreventAdminlessGroupsConfig) where objectSchema = field "config" schema oldPreventAdminlessGroupsConfigObjectSchema :: ObjectSchema SwaggerDoc PreventAdminlessGroupsConfig diff --git a/services/brig/docs/swagger-v17.json b/services/brig/docs/swagger-v17.json new file mode 100644 index 00000000000..84763a895ae --- /dev/null +++ b/services/brig/docs/swagger-v17.json @@ -0,0 +1 @@ +{"info":{"description":"## Authentication / Authorization\n\nThe end-points in this API support differing authorization protocols:\nsome are unauthenticated (`/api-version`, `/login`), some require\n[zauth](), and some support both [zauth]() and [oauth]().\n\nThe end-points that require zauth are labelled so in the description\nbelow. The end-points that support oauth as an alternative to zauth\nhave the required oauth scopes listed in the same description.\n\nFuther reading:\n- https://docs.wire.com/developer/reference/oauth.html\n- https://github.com/wireapp/wire-server/blob/develop/libs/wire-api/src/Wire/API/Routes/Public.hs (search for HasSwagger instances)\n- `curl https://staging-nginz-https.zinfra.io/v4/api/swagger.json | jq '.security, .securityDefinitions`\n\n### SSO Endpoints\n\n#### Overview\n\n`/sso/metadata` will be requested by the IdPs to learn how to talk to wire.\n\n`/sso/initiate-login`, `/sso/finalize-login` are for the SAML authentication handshake performed by a user in order to log into wire. They are not exactly standard in their details: they may return HTML or XML; redirect to error URLs instead of throwing errors, etc.\n\n`/identity-providers` end-points are for use in the team settings page when IdPs are registered. They talk json.\n\n\n#### Configuring IdPs\n\nIdPs usually allow you to copy the metadata into your clipboard. That should contain all the details you need to post the idp in your team under `/identity-providers`. (Team id is derived from the authorization credentials of the request.)\n\n##### okta.com\n\nOkta will ask you to provide two URLs when you set it up for talking to wireapp:\n\n1. The `Single sign on URL`. This is the end-point that accepts the user's credentials after successful authentication against the IdP. Choose `/sso/finalize-login` with schema and hostname of the wire server you are configuring.\n\n2. The `Audience URI`. You can find this in the metadata returned by the `/sso/metadata` end-point. It is the contents of the `md:OrganizationURL` element.\n\n##### centrify.com\n\nCentrify allows you to upload the metadata xml document that you get from the `/sso/metadata` end-point. You can also enter the metadata url and have centrify retrieve the xml, but to guarantee integrity of the setup, the metadata should be copied from the team settings page and pasted into the centrify setup page without any URL indirections.\n\n## Federation errors\n\nEndpoints involving federated calls to other domains can return some extra failure responses, common to all endpoints. Instead of listing them as possible responses for each endpoint, we document them here.\n\nFor errors that are more likely to be transient, we suggest clients to retry whatever request resulted in the error. Transient errors are indicated explicitly below.\n\n**Note**: when a failure occurs as a result of making a federated RPC to another backend, the error response contains the following extra fields:\n\n - `type`: \"federation\" (just the literal string in quotes, which can be used as an error type identifier when parsing errors)\n - `domain`: the target backend of the RPC that failed;\n - `path`: the path of the RPC that failed.\n\n### Domain errors\n\nErrors in this category result from trying to communicate with a backend that is considered non-existent or invalid. They can result from invalid user input or client issues, but they can also be a symptom of misconfiguration in one or multiple backends. These errors have a 4xx status code.\n\n - **Remote backend not found** (status: 422, label: `invalid-domain`): This backend attempted to contact a backend which does not exist or is not properly configured. For the most part, clients can consider this error equivalent to a domain not existing, although it should be noted that certain mistakes in the DNS configuration on a remote backend can lead to the backend not being recognized, and hence to this error. It is therefore not advisable to take any destructive action upon encountering this error, such as deleting remote users from conversations.\n - **Federation denied locally** (status: 400, label: `federation-denied`): This backend attempted an RPC to a non-whitelisted backend. Similar considerations as for the previous error apply.\n - **Federation not enabled** (status: 400, label: `federation-not-enabled`): Federation has not been configured for this backend. This will happen if a federation-aware client tries to talk to a backend for which federation is disabled, or if federation was disabled on the backend after reaching a federation-specific state (e.g. conversations with remote users). There is no way to cleanly recover from these errors at this point.\n\n### Local federation errors\n\nAn error in this category likely indicates an issue with the configuration of federation on the local backend. Possibly transient errors are indicated explicitly below. All these errors have a 500 status code.\n\n - **Federation unavailable** (status: 500, label: `federation-not-available`): Federation is configured for this backend, but the local federator cannot be reached. This can be transient, so clients should retry the request.\n - **Federation not implemented** (status: 422, label: `federation-not-implemented`): Federated behaviour for a certain endpoint is not yet implemented.\n - **Federator discovery failed** (status: 400, label: `discovery-failure`): A DNS error occurred during discovery of a remote backend. This can be transient, so clients should retry the request.\n - **Local federation error** (status: 500, label: `federation-local-error`): An error occurred in the communication between this backend and its local federator. These errors are most likely caused by bugs in the backend, and should be reported as such.\n\n### Remote federation errors\n\nErrors in this category are returned in case of communication issues between the local backend and a remote one, or if the remote side encountered an error while processing an RPC. Some errors in this category might be caused by incorrect client behaviour, wrong user input, or incorrect certificate configuration. Possibly transient errors are indicated explicitly. We use non-standard 5xx status codes for these errors.\n\n - **HTTP2 error** (status: 533, label: `federation-http2-error`): The current federator encountered an error when making an HTTP2 request to a remote one. Check the error message for more details.\n - **Connection refused** (status: 521, label: `federation-connection-refused`): The local federator could not connect to a remote one. This could be transient, so clients should retry the request.\n - **TLS failure**: (status: 525, label: `federation-tls-error`): An error occurred during the TLS handshake between the local federator and a remote one. This is most likely due to an issue with the certificate on the remote end.\n - **Remote federation error** (status: 533, label: `federation-remote-error`): The remote backend could not process a request coming from this backend. Check the error message for more details.\n - **Version negotiation error** (status: 533, label: `federation-version-error`): The remote backend returned invalid version information.\n\n### Backend compatibility errors\n\nAn error in this category will be returned when this backend makes an invalid or unsupported RPC to another backend. This can indicate some incompatibility between backends or a backend bug. These errors are unlikely to be transient, so retrying requests is *not* advised.\n\n - **Version mismatch** (status: 531, label: `federation-version-mismatch`): A remote backend is running an unsupported version of the federator.\n - **Invalid content type** (status: 533, label: `federation-invalid-content-type`): An RPC to another backend returned with an invalid content type.\n - **Unsupported content type** (status: 533, label: `federation-unsupported-content-type`): An RPC to another backend returned with an unsupported content type.\n","title":"Wire-Server API","version":""},"servers":[{"url":"/v17"}],"paths":{"/api-version":{"get":{"description":" [internal route ID: \"get-version\"]\n\n","operationId":"get-version","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/VersionInfo_NTEzMTgzNDQ0"}}},"description":""}}}},"/users/{uid_domain}/{uid}":{"get":{"summary":"Get a user by Domain and UserId","description":" [internal route ID: \"get-user-qualified\"]\n\n","operationId":"get-user-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}}},"description":"User found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`uid_domain` or `uid` or User not found (label: `not-found`)"}}}},"/users/{uid}/email":{"put":{"summary":"Resend email address validation email.","description":" [internal route ID: \"update-user-email\"]\n\nIf the user has a pending email validation, the validation email will be resent.","operationId":"update-user-email","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/EmailUpdate_NjQ5MDg1OTY0"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""}}}},"/list-users":{"post":{"summary":"List users","description":" [internal route ID: \"list-users-by-ids-or-handles\"]\n\nThe 'qualified_ids' and 'qualified_handles' parameters are mutually exclusive.","operationId":"list-users-by-ids-or-handles","parameters":[{"description":"Include whether each local user can currently be contacted","in":"query","name":"include-contact-status","required":false,"schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ListUsersQuery"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ListUsersById_LTQ5MTE3NDc0"}}},"description":""}}}},"/verification-code/send":{"post":{"summary":"Send a verification code to a given email address.","description":" [internal route ID: \"send-verification-code\"]\n\n","operationId":"send-verification-code","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SendVerificationCode_MjgxNDgxODE2"}}},"required":true},"responses":{"200":{"description":"Verification code sent."}}}},"/users/{uid}/rich-info":{"get":{"summary":"Get a user's rich info","description":" [internal route ID: \"get-rich-info\"]\n\n","operationId":"get-rich-info","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RichInfoAssocList"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RichInfoAssocList"}}},"description":"Rich info about the user"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}}}},"/users/{uid_domain}/{uid}/supported-protocols":{"get":{"summary":"Get a user's supported protocols","description":" [internal route ID: \"get-supported-protocols\"]\n\n","operationId":"get-supported-protocols","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array","uniqueItems":true}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array","uniqueItems":true}}},"description":"Protocols supported by the user"}}}},"/users/{uid}/searchable":{"post":{"summary":"Set user's visibility in search","description":" [internal route ID: \"set-user-searchable\"]\n\n","operationId":"set-user-searchable","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SetSearchable_NDAxODAxODI5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""}}}},"/self":{"get":{"summary":"Get your own profile","description":" [internal route ID: \"get-self\"]\n\n\nOAuth scope: `read:self`","operationId":"get-self","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/User_NjA4OTQwMTQ4"}}},"description":""}}},"put":{"summary":"Update your profile.","description":" [internal route ID: \"put-self\"]\n\n","operationId":"put-self","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserUpdate_MjQ4NTEwOTQz"}}},"required":true},"responses":{"200":{"description":"User updated"}}},"delete":{"summary":"Initiate account deletion.","description":" [internal route ID: \"delete-self\"]\n\nif the account has a verified identity, a verification code is sent and needs to be confirmed to authorise the deletion. if the account has no verified identity but a password, it must be provided. if password is correct, or if neither a verified identity nor a password exists, account deletion is scheduled immediately.","operationId":"delete-self","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteUser_NjE0MjE2Mjkz"}}},"required":true},"responses":{"200":{"description":"Deletion is initiated."},"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionCodeTimeout_LTU1MTk0NDI3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeletionCodeTimeout_LTU1MTk0NDI3"}}},"description":"Deletion is pending verification with a code."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-user","message":"Invalid user"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-user"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid user (label: `invalid-user`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-self-delete-for-team-owner","message":"Team owners are not allowed to delete themselves; ask a fellow owner"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-self-delete-for-team-owner","pending-delete","missing-auth","invalid-credentials","invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team owners are not allowed to delete themselves; ask a fellow owner (label: `no-self-delete-for-team-owner`)\n\nA verification code for account deletion is still pending (label: `pending-delete`)\n\nRe-authentication via password required (label: `missing-auth`)\n\nAuthentication failed (label: `invalid-credentials`)\n\nInvalid verification code (label: `invalid-code`)"}}}},"/self/email":{"delete":{"summary":"Remove your email address.","description":" [internal route ID: \"remove-email\"]\n\nYour email address can only be removed if you also have a phone number.","operationId":"remove-email","responses":{"200":{"description":"Identity Removed"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"last-identity","message":"The last user identity cannot be removed."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["last-identity","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"last-identity","message":"The last user identity cannot be removed."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["last-identity","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The last user identity cannot be removed. (label: `last-identity`)\n\nThe user has no verified email (label: `no-identity`)"}}}},"/self/password":{"put":{"summary":"Change your password.","description":" [internal route ID: \"change-password\"]\n\n","operationId":"change-password","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordChange_MTgzMDM2NTY2"}}},"required":true},"responses":{"200":{"description":"Password Changed"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nThe user has no verified email (label: `no-identity`)"},"409":{"content":{"application/json":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password change, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password change, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password change, new and old password must be different. (label: `password-must-differ`)"}}},"head":{"summary":"Check that your password is set.","description":" [internal route ID: \"check-password-exists\"]\n\n","operationId":"check-password-exists","responses":{"200":{"description":"Password is set"},"404":{"description":"Password is not set"}}}},"/self/locale":{"put":{"summary":"Change your locale.","description":" [internal route ID: \"change-locale\"]\n\n","operationId":"change-locale","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LocaleUpdate_LTgzNjgyOTEw"}}},"required":true},"responses":{"200":{"description":"Local Changed"}}}},"/self/handle":{"put":{"summary":"Change your handle.","description":" [internal route ID: \"change-handle\"]\n\n","operationId":"change-handle","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/HandleUpdate_NTI4NDk1OTAx"}}},"required":true},"responses":{"200":{"description":"Handle Changed"}}}},"/self/supported-protocols":{"put":{"summary":"Change your supported protocols","description":" [internal route ID: \"change-supported-protocols\"]\n\n","operationId":"change-supported-protocols","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SupportedProtocolUpdate_LTE3Njk3MDM4"}}},"required":true},"responses":{"200":{"description":"Supported protocols changed"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-protocol-error","message":"MLS protocol cannot be removed"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS protocol cannot be removed (label: `mls-protocol-error`)"}}}},"/upgrade-personal-to-team":{"post":{"summary":"Upgrade personal user to team owner","description":" [internal route ID: \"upgrade-personal-to-team\"]\n\n","operationId":"upgrade-personal-to-team","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/BindingNewTeamUser_LTY0MDQxMDEw"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserTeam_MzI4NDQ1Mzkw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateUserTeam_MzI4NDQ1Mzkw"}}},"description":"Team created"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"user-already-in-a-team","message":"Switching teams is not allowed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-already-in-a-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-already-in-a-team","message":"Switching teams is not allowed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-already-in-a-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Switching teams is not allowed (label: `user-already-in-a-team`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"User not found (label: `not-found`)"}}}},"/register":{"post":{"summary":"Register a new user.","description":" [internal route ID: \"register\"]\n\nIf the environment where the registration takes place is private and a registered email address is not whitelisted, a 403 error is returned.","operationId":"register","parameters":[{"in":"header","name":"X-Forwarded-For","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewUser_PlainTextPassword_8_LTI4MzI5NzQx"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User_NjA4OTQwMTQ4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/User_NjA4OTQwMTQ4"}}},"description":"User created and pending activation","headers":{"Location":{"description":"UserId","schema":{"format":"uuid","type":"string"}},"Set-Cookie":{"description":"Cookie","schema":{"type":"string"}}}},"400":{"content":{"application/json":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code","invalid-email","invalid-phone"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code","invalid-email","invalid-phone"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid invitation code. (label: `invalid-invitation-code`)\n\nInvalid e-mail address. (label: `invalid-email`)\n\nInvalid mobile phone number (label: `invalid-phone`) or `body` or `X-Forwarded-For`"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unauthorized","message":"Unauthorized e-mail address"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorized","missing-identity","blacklisted-email","too-many-team-members","user-creation-restricted","ephemeral-user-creation-disabled","managed-by-scim"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorized","message":"Unauthorized e-mail address"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorized","missing-identity","blacklisted-email","too-many-team-members","user-creation-restricted","ephemeral-user-creation-disabled","managed-by-scim"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorized e-mail address (label: `unauthorized`)\n\nUsing an invitation code requires registering the given email. (label: `missing-identity`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nToo many members in this team. (label: `too-many-team-members`)\n\nThis instance does not allow creation of personal users or teams. (label: `user-creation-restricted`)\n\nEphemeral user creation is disabled on this instance. (label: `ephemeral-user-creation-disabled`)\n\nUpdating name is not allowed, because it is managed by SCIM, or E2EId is enabled (label: `managed-by-scim`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"invalid-code","message":"User does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"User does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"User does not exist (label: `invalid-code`)\n\nInvalid activation code (label: `invalid-code`)"},"409":{"content":{"application/json":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}}}},"/delete":{"post":{"summary":"Verify account deletion with a code.","description":" [internal route ID: \"verify-delete\"]\n\n","operationId":"verify-delete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/VerifyDeleteUser_Njc1NDQ1MDIy"}}},"required":true},"responses":{"200":{"description":"Deletion is initiated."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-code","message":"Invalid verification code"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid verification code (label: `invalid-code`)"}}}},"/activate":{"get":{"summary":"Activate (i.e. confirm) an email address.","description":" [internal route ID: \"get-activate\"]\n\nSee also 'POST /activate' which has a larger feature set.","operationId":"get-activate","parameters":[{"description":"Activation key","in":"query","name":"key","required":true,"schema":{"type":"string"}},{"description":"Activation code","in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}}},"description":"Activation successful.\n\nActivation successful. (Dry run)\n\nActivation successful."},"204":{"description":"A recent activation was already successful."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-phone","message":"Invalid mobile phone number"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-phone","invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `code` or `key`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"Invalid activation code"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}}},"post":{"summary":"Activate (i.e. confirm) an email address.","description":" [internal route ID: \"post-activate\"]\n\nActivation only succeeds once and the number of failed attempts for a valid key is limited.","operationId":"post-activate","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Activate_MzUzNzIxODUw"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}}},"description":"Activation successful.\n\nActivation successful. (Dry run)\n\nActivation successful."},"204":{"description":"A recent activation was already successful."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-phone","message":"Invalid mobile phone number"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-phone","invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"Invalid activation code"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}}}},"/activate/send":{"post":{"summary":"Send (or resend) an email activation code.","description":" [internal route ID: \"post-activate-send\"]\n\n","operationId":"post-activate-send","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SendActivationCode_LTgyNDAxNzEy"}}},"required":true},"responses":{"200":{"description":"Activation code sent."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"blacklisted-email","message":"The given e-mail address has been blacklisted due to a permanent bounce or a complaint."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["blacklisted-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"},"451":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":451,"label":"domain-blocked-for-registration","message":"[Customer extension] The email domain has been blocked for Wire users. Please contact your IT department."},"properties":{"code":{"enum":[451],"type":"integer"},"label":{"enum":["domain-blocked-for-registration"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"[Customer extension] The email domain has been blocked for Wire users. Please contact your IT department. (label: `domain-blocked-for-registration`)"}}}},"/password-reset":{"post":{"summary":"Initiate a password reset.","description":" [internal route ID: \"post-password-reset\"]\n\n","operationId":"post-password-reset","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewPasswordReset_LTEyNzAxMTcy"}}},"required":true},"responses":{"201":{"description":"Password reset code created and sent by email."}}}},"/password-reset/complete":{"post":{"summary":"Complete a password reset.","description":" [internal route ID: \"post-password-reset-complete\"]\n\n","operationId":"post-password-reset-complete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CompletePasswordReset_NDcyMjY5OTc4"}}},"required":true},"responses":{"200":{"description":"Password reset successful."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-code","message":"Invalid password reset code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)"}}}},"/users/{uid_domain}/{uid}/clients/{client}":{"get":{"summary":"Get a specific client of a user","description":" [internal route ID: \"get-user-client-qualified\"]\n\nHint: to list all clients of one or more users, use POST /users/list-clients.","operationId":"get-user-client-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PubClient"}}},"description":""}}}},"/users/list-clients":{"post":{"summary":"List all clients for a set of user ids","description":" [internal route ID: \"list-clients-bulk@v2\"]\n\nIf a backend is unreachable, the clients from that backend will be omitted from the response","operationId":"list-clients-bulk@v2","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LimitedQualifiedUserIdList_500"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"qualified_user_map":{"$ref":"#/components/schemas/QualifiedUserMap_Set_PubClient"}},"type":"object"}}},"description":""}}}},"/users/{uid_domain}/{uid}/prekeys/{client}":{"get":{"summary":"Get a prekey for a specific client of a user.","description":" [internal route ID: \"get-users-prekeys-client-qualified\"]\n\n","operationId":"get-users-prekeys-client-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientPrekey_LTcyODUzMTcw"}}},"description":""}}}},"/users/{uid_domain}/{uid}/prekeys":{"get":{"summary":"Get a prekey for each client of a user.","description":" [internal route ID: \"get-users-prekey-bundle-qualified\"]\n\n","operationId":"get-users-prekey-bundle-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PrekeyBundle_MzgzOTk4MjYz"}}},"description":""}}}},"/users/list-prekeys":{"post":{"summary":"(deprecated) Given a map of user IDs to client IDs return a prekey for each one.","description":" [internal route ID: \"get-multi-user-prekey-bundle-qualified\"]\n\nYou can't request information for more users than maximum conversation size.","operationId":"get-multi-user-prekey-bundle-qualified","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QualifiedUserClients"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QualifiedUserClientPrekeyMapV4_MzcxOTAxMTYy"}}},"description":""}}}},"/clients":{"get":{"summary":"List the registered clients","description":" [internal route ID: \"list-clients\"]\n\n","operationId":"list-clients","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"},"type":"array"}}},"description":"List of clients"}}},"post":{"summary":"Register a new client","description":" [internal route ID: \"add-client\"]\n\n","operationId":"add-client","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewClient_ODg1NjY4Njgy"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}}},"description":"Client registered","headers":{"Location":{"description":"Client ID","schema":{"type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"bad-request","message":"Malformed prekeys uploaded"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["bad-request"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMalformed prekeys uploaded (label: `bad-request`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed","missing-auth","too-many-clients"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nRe-authentication via password required (label: `missing-auth`)\n\nToo many clients (label: `too-many-clients`)"}}}},"/clients/{client}":{"get":{"summary":"Get a registered client by ID","description":" [internal route ID: \"get-client\"]\n\n","operationId":"get-client","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}}},"description":"Client found"},"404":{"description":"`client` or Client not found(**Note**: This error has an empty body for legacy reasons)"}}},"put":{"summary":"Update a registered client","description":" [internal route ID: \"update-client\"]\n\n","operationId":"update-client","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateClient_NzU5MjA4MzI1"}}},"required":true},"responses":{"200":{"description":"Client updated"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-duplicate-public-key","message":"MLS public key for the given signature scheme already exists"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-duplicate-public-key","bad-request"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMLS public key for the given signature scheme already exists (label: `mls-duplicate-public-key`)\n\nMalformed prekeys uploaded (label: `bad-request`)"}}},"delete":{"summary":"Delete an existing client","description":" [internal route ID: \"delete-client\"]\n\n","operationId":"delete-client","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RmClient_MTQ5OTI2MDY3"}}},"required":true},"responses":{"200":{"description":"Client deleted"}}}},"/clients/{client}/capabilities":{"get":{"summary":"Read back what the client has been posting about itself","description":" [internal route ID: \"get-client-capabilities\"]\n\n","operationId":"get-client-capabilities","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientCapabilityList"}}},"description":""}}}},"/clients/{client}/prekeys":{"get":{"summary":"List the remaining prekey IDs of a client","description":" [internal route ID: \"get-client-prekeys\"]\n\n","operationId":"get-client-prekeys","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"maximum":65535,"minimum":0,"type":"integer"},"type":"array"}}},"description":""}}}},"/clients/{client}/nonce":{"get":{"summary":"Get a new nonce for a client CSR","description":" [internal route ID: \"get-nonce\"]\n\nGet a new nonce for a client CSR, specified in the response header `Replay-Nonce` as a uuidv4 in base64url encoding.","operationId":"get-nonce","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content","headers":{"Cache-Control":{"schema":{"type":"string"}},"Replay-Nonce":{"schema":{"type":"string"}}}}}},"head":{"summary":"Get a new nonce for a client CSR","description":" [internal route ID: \"head-nonce\"]\n\nGet a new nonce for a client CSR, specified in the response header `Replay-Nonce` as a uuidv4 in base64url encoding.","operationId":"head-nonce","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"No Content","headers":{"Cache-Control":{"schema":{"type":"string"}},"Replay-Nonce":{"schema":{"type":"string"}}}}}}},"/clients/{cid}/access-token":{"post":{"summary":"Create a JWT DPoP access token","description":" [internal route ID: \"create-access-token\"]\n\nCreate an JWT DPoP access token for the client CSR, given a JWT DPoP proof, specified in the `DPoP` header. The access token will be returned in the JSON response body as a JWT DPoP token.","operationId":"create-access-token","parameters":[{"description":"ClientId","in":"path","name":"cid","required":true,"schema":{"type":"string"}},{"in":"header","name":"DPoP","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DPoPAccessTokenResponse_LTgyODU5MDE3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DPoPAccessTokenResponse_LTgyODU5MDE3"}}},"description":"Access token created","headers":{"Cache-Control":{"schema":{"type":"string"}}}}}}},"/connections/{uid_domain}/{uid}":{"get":{"summary":"Get an existing connection to another user (local or remote)","description":" [internal route ID: \"get-connection\"]\n\n","operationId":"get-connection","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection found"},"404":{"description":"`uid_domain` or `uid` or Connection not found(**Note**: This error has an empty body for legacy reasons)"}}},"put":{"summary":"Update a connection to another user","description":" [internal route ID: \"update-connection\"]\n\n","operationId":"update-connection","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConnectionUpdate_LTU3MTA1OTA5"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection updated"},"204":{"description":"Connection unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-user","message":"Invalid user"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-user"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid user (label: `invalid-user`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-identity","message":"The user has no verified email"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-identity","bad-conn-update","not-connected","connection-limit","missing-legalhold-consent","missing-legalhold-consent-old-clients"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The user has no verified email (label: `no-identity`)\n\nInvalid status transition (label: `bad-conn-update`)\n\nUsers are not connected (label: `not-connected`)\n\nToo many sent/accepted connections (label: `connection-limit`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)"}}},"post":{"summary":"Create a connection to another user","description":" [internal route ID: \"create-connection\"]\n\nYou can have no more than 1000 connections in accepted or sent state","operationId":"create-connection","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection existed"},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection was created"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-user","message":"Invalid user"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-user"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid user (label: `invalid-user`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-identity","message":"The user has no verified email"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-identity","connection-limit","missing-legalhold-consent","missing-legalhold-consent-old-clients"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The user has no verified email (label: `no-identity`)\n\nToo many sent/accepted connections (label: `connection-limit`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)"}}}},"/list-connections":{"post":{"summary":"List the connections to other users, including remote users","description":" [internal route ID: \"list-connections\"]\n\nThe IDs returned by this endpoint are paginated. To get the first page, make a call with the `paging_state` field set to `null` (or omitted). Whenever the `has_more` field of the response is set to `true`, more results are available, and they can be obtained by calling the endpoint again, but this time passing the value of `paging_state` returned by the previous call. One can continue in this fashion until all results are returned, which is indicated by `has_more` being `false`. Note that `paging_state` should be considered an opaque token. It should not be inspected, or stored, or reused across multiple unrelated invocations of the endpoint.","operationId":"list-connections","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetMuliTabPgRqs_ConcLOrm501_LTIwMjA4NzYw"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MultiTabePg_ConcsLOrRmU_MTgwMzU4OTU5"}}},"description":""}}}},"/search/contacts":{"get":{"summary":"Search for users","description":" [internal route ID: \"search-contacts\"]\n\n","operationId":"search-contacts","parameters":[{"description":"Search query

The search query is normalized: lower-cased and diacritics are removed ('Björn' becomes 'bjorn'). The normalized search query matches accounts that, in this order of priority:

  • are equal to the normalized full handle;
  • are equal to the normalized full user display name;
  • prefix-match the normalized handle;
  • prefix-match the normalized user display name.

NB: '@' Does NOT do anything special, ignoring user display names.

See also: [authoritative ElasticSearch query](https://github.com/wireapp/wire-server/blob/83c25cca6a5e9d2205c102410b452eb78fc50a00/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs#L251-L288)

","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Searched domain. Note: This is optional only for backwards compatibility, future versions will mandate this.","in":"query","name":"domain","required":false,"schema":{"type":"string"}},{"description":"Number of results to return (min: 1, max: 500, default 15)","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":500,"minimum":1,"type":"integer"}},{"description":"Only user types. Omitted or empty (type=) means no filtering.","in":"query","name":"type","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SearchResult_Contact_OTExNzg4MTE0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `insufficient-permissions`)"}}}},"/properties/{key}":{"get":{"summary":"Get a property value","description":" [internal route ID: \"get-property\"]\n\n","operationId":"get-property","parameters":[{"in":"path","name":"key","required":true,"schema":{"format":"printable","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertyValue"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PropertyValue"}}},"description":"The property value"},"404":{"description":"`key` or Property not found(**Note**: This error has an empty body for legacy reasons)"}}},"put":{"summary":"Set a user property","description":" [internal route ID: \"set-property\"]\n\n","operationId":"set-property","parameters":[{"in":"path","name":"key","required":true,"schema":{"format":"printable","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PropertyValue"}}},"required":true},"responses":{"200":{"description":"Property set"}}},"delete":{"summary":"Delete a property","description":" [internal route ID: \"delete-property\"]\n\n","operationId":"delete-property","parameters":[{"in":"path","name":"key","required":true,"schema":{"format":"printable","type":"string"}}],"responses":{"200":{"description":"Property deleted"}}}},"/properties":{"get":{"summary":"List all property keys","description":" [internal route ID: \"list-property-keys\"]\n\n","operationId":"list-property-keys","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ASCII"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/ASCII"},"type":"array"}}},"description":"List of property keys"}}},"delete":{"summary":"Clear all properties","description":" [internal route ID: \"clear-properties\"]\n\n","operationId":"clear-properties","responses":{"200":{"description":"Properties cleared"}}}},"/properties-values":{"get":{"summary":"List all properties with key and value","description":" [internal route ID: \"list-properties\"]\n\n","operationId":"list-properties","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PropertyKeysAndValues"}}},"description":""}}}},"/mls/key-packages/self/{client}":{"put":{"summary":"Upload a fresh batch of key packages and replace the old ones","description":" [internal route ID: \"mls-key-packages-replace\"]\n\nThe request body should be a json object containing a list of base64-encoded key packages. Use this sparingly.","operationId":"mls-key-packages-replace","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Comma-separated list of ciphersuites in hex format (e.g. 0x0002)","in":"query","name":"ciphersuites","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageUpload_NTQ2Mjk2NzEx"}}},"required":true},"responses":{"201":{"description":"Key packages replaced"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-protocol-error","message":"MLS protocol error"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `ciphersuites`\n\nMLS protocol error (label: `mls-protocol-error`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-identity-mismatch","message":"Key package credential does not match qualified client ID"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-identity-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Key package credential does not match qualified client ID (label: `mls-identity-mismatch`)"}}},"post":{"summary":"Upload a fresh batch of key packages","description":" [internal route ID: \"mls-key-packages-upload\"]\n\nThe request body should be a json object containing a list of base64-encoded key packages.","operationId":"mls-key-packages-upload","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageUpload_NTQ2Mjk2NzEx"}}},"required":true},"responses":{"201":{"description":"Key packages uploaded"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-protocol-error","message":"MLS protocol error"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMLS protocol error (label: `mls-protocol-error`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-identity-mismatch","message":"Key package credential does not match qualified client ID"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-identity-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Key package credential does not match qualified client ID (label: `mls-identity-mismatch`)"}}},"delete":{"summary":"Delete all key packages for a given ciphersuite and client","description":" [internal route ID: \"mls-key-packages-delete\"]\n\n","operationId":"mls-key-packages-delete","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Ciphersuite in hex format (e.g. 0x0002)","in":"query","name":"ciphersuite","required":true,"schema":{"type":"number"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteKeyPackages_LTQxNTcxNjY3"}}},"required":true},"responses":{"201":{"description":"OK"}}}},"/mls/key-packages/claim/{user_domain}/{user}":{"post":{"summary":"Claim one key package for each client of the given user","description":" [internal route ID: \"mls-key-packages-claim\"]\n\nOnly key packages for the specified ciphersuite are claimed.","operationId":"mls-key-packages-claim","parameters":[{"in":"path","name":"user_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"user","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Ciphersuite in hex format (e.g. 0x0002)","in":"query","name":"ciphersuite","required":true,"schema":{"type":"number"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyPackageBundle_MjU2MjY0MDU2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageBundle_MjU2MjY0MDU2"}}},"description":"Claimed key packages"}}}},"/mls/key-packages/self/{client}/count":{"get":{"summary":"Return the number of unclaimed key packages for a given ciphersuite and client","description":" [internal route ID: \"mls-key-packages-count\"]\n\n","operationId":"mls-key-packages-count","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Ciphersuite in hex format (e.g. 0x0002)","in":"query","name":"ciphersuite","required":true,"schema":{"type":"number"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyPackageCount_LTYwNDg5MDcz"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageCount_LTYwNDg5MDcz"}}},"description":"Number of key packages"}}}},"/handles":{"post":{"summary":"Check availability of user handles","description":" [internal route ID: \"check-user-handles\"]\n\n","operationId":"check-user-handles","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CheckHandles_LTc0OTkxMzAx"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Handle"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Handle"},"type":"array"}}},"description":"List of free handles"}}}},"/handles/{handle}":{"head":{"summary":"Check whether a user handle can be taken","description":" [internal route ID: \"check-user-handle\"]\n\n","operationId":"check-user-handle","parameters":[{"in":"path","name":"handle","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"Handle is taken"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-handle","message":"The given handle is invalid (less than 2 or more than 256 characters; chars not in \"a-z0-9_.-\"; or on the blocklist)"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-handle"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given handle is invalid (less than 2 or more than 256 characters; chars not in \"a-z0-9_.-\"; or on the blocklist) (label: `invalid-handle`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Handle not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`handle` not found\n\nHandle not found (label: `not-found`)"}}}},"/teams/{tid}/search":{"get":{"summary":"Browse team for members (requires add-user permission)","description":" [internal route ID: \"browse-team\"]\n\n","operationId":"browse-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Search expression","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"description":"Role filter, eg. `member,partner`. Empty list means do not filter.","in":"query","name":"frole","required":false,"schema":{"items":{"enum":["owner","admin","member","partner"],"type":"string"},"type":"array"}},{"description":"Can be one of name, handle, email, saml_idp, managed_by, role, created_at.","in":"query","name":"sortby","required":false,"schema":{"enum":["name","handle","email","saml_idp","managed_by","role","created_at"],"type":"string"}},{"description":"Can be one of asc, desc.","in":"query","name":"sortorder","required":false,"schema":{"enum":["asc","desc"],"type":"string"}},{"description":"Number of results to return (min: 1, max: 500, default: 15)","in":"query","name":"size","required":false,"schema":{"maximum":500,"minimum":1,"type":"integer"}},{"description":"Optional, when not specified, the first page will be returned. Every returned page contains a `paging_state`, this should be supplied to retrieve the next page.","in":"query","name":"pagingState","required":false,"schema":{"type":"string"}},{"description":"Filter for (un-)verified email","in":"query","name":"email","required":false,"schema":{"enum":["unverified","verified"],"type":"string"}},{"description":"Optional, return only non-searchable members when false.","in":"query","name":"searchable","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResult_TeamContact_LTE0NjQ0NzMw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SearchResult_TeamContact_LTE0NjQ0NzMw"}}},"description":"Search results"}}}},"/access":{"post":{"summary":"Obtain an access tokens for a cookie","description":" [internal route ID: \"access\"]\n\nYou can provide only a cookie or a cookie and token. Every other combination is invalid. Access tokens can be given as query parameter or authorisation header, with the latter being preferred.","operationId":"access","parameters":[{"in":"query","name":"client_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)"}}}},"/login":{"post":{"summary":"Authenticate a user to obtain a cookie and first access token","description":" [internal route ID: \"login\"]\n\nLogins are throttled at the server's discretion","operationId":"login","parameters":[{"description":"Request a persistent cookie instead of a session cookie","in":"query","name":"persist","required":false,"schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Login_LTgyNTIzMTM1"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed","pending-activation","suspended","invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nAccount pending activation (label: `pending-activation`)\n\nAccount suspended (label: `suspended`)\n\nAuthentication failed (label: `invalid-credentials`)"}}}},"/access/logout":{"post":{"summary":"Log out in order to remove a cookie from the server","description":" [internal route ID: \"logout\"]\n\nCalling this endpoint will effectively revoke the given cookie and subsequent calls to /access with the same cookie will result in a 403.","operationId":"logout","responses":{"200":{"description":"Logout"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)"}}}},"/access/self/email":{"put":{"summary":"Change your email address","description":" [internal route ID: \"change-self-email\"]\n\n","operationId":"change-self-email","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/EmailUpdate_NjQ5MDg1OTY0"}}},"required":true},"responses":{"202":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"Update accepted and pending activation of the new email"},"204":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"No update, current and new email address are the same\n\nEmail address activated"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid e-mail address. (label: `invalid-email`) or `body`"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","blacklisted-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}}}},"/cookies":{"get":{"summary":"Retrieve the list of cookies currently stored for the user","description":" [internal route ID: \"list-cookies\"]\n\n","operationId":"list-cookies","parameters":[{"description":"Filter by label (comma-separated list)","in":"query","name":"labels","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CookieList_LTM4MzYwNzAz"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CookieList_LTM4MzYwNzAz"}}},"description":"List of cookies"}}}},"/cookies/remove":{"post":{"summary":"Revoke stored cookies","description":" [internal route ID: \"remove-cookies\"]\n\n","operationId":"remove-cookies","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RemoveCookies_OTYwMTI0NDMy"}}},"required":true},"responses":{"200":{"description":"Cookies revoked"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)"}}}},"/calls/config/v2":{"get":{"summary":"Retrieve all TURN server addresses and credentials. Clients are expected to do a DNS lookup to resolve the IP addresses of the given hostnames ","description":" [internal route ID: \"get-calls-config-v2\"]\n\n","operationId":"get-calls-config-v2","parameters":[{"description":"Limit resulting list. Allowed values [1..10]","in":"query","name":"limit","required":false,"schema":{"maximum":10,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RTCConfiguration_LTIwOTc4OTk0"}}},"description":""}}}},"/teams/{tid}/invitations":{"get":{"summary":"List the sent team invitations","description":" [internal route ID: \"get-team-invitations\"]\n\n","operationId":"get-team-invitations","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Invitation id to start from (ascending).","in":"query","name":"start","required":false,"schema":{"format":"uuid","type":"string"}},{"description":"Number of results to return (default 100, max 500).","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":500,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitationList_ODk4NTQxODc3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InvitationList_ODk4NTQxODc3"}}},"description":"List of sent invitations"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}}},"post":{"summary":"Create and send a new team invitation.","description":" [internal route ID: \"send-team-invitation\"]\n\nInvitations are sent by email. The maximum allowed number of pending team invitations is equal to the team size.","operationId":"send-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InvitationRequest_LTcyMDIzNDc0"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}}},"description":"Invitation was created and sent.","headers":{"Location":{"schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code","invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid invitation code. (label: `invalid-invitation-code`)\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions","too-many-team-invitations","blacklisted-email","no-identity","no-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)\n\nToo many team invitations for this team (label: `too-many-team-invitations`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nThe user has no verified email (label: `no-identity`)\n\nThis operation requires the user to have a verified email address. (label: `no-email`)"}}}},"/teams/{tid}/invitations/{iid}":{"get":{"summary":"Get a pending team invitation by ID.","description":" [internal route ID: \"get-team-invitation\"]\n\n","operationId":"get-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"iid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}}},"description":"Invitation"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Notification not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Notification not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `iid` or Notification not found. (label: `not-found`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"duplicate-entry","message":"Entry already exists"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["duplicate-entry"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Entry already exists (label: `duplicate-entry`)"}}},"delete":{"summary":"Delete a pending team invitation by ID.","description":" [internal route ID: \"delete-team-invitation\"]\n\n","operationId":"delete-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"iid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"Invitation deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}}}},"/teams/invitations/info":{"get":{"summary":"Get invitation info given a code.","description":" [internal route ID: \"get-team-invitation-info\"]\n\n","operationId":"get-team-invitation-info","parameters":[{"description":"Invitation code","in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitationUserView_LTUyMTE3Nzkz"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InvitationUserView_LTUyMTE3Nzkz"}}},"description":"Invitation info"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `code`\n\nInvalid invitation code. (label: `invalid-invitation-code`)"}}}},"/teams/invitations/by-email":{"head":{"summary":"Check if there is an invitation pending given an email address.","description":" [internal route ID: \"head-team-invitations\"]\n\n","operationId":"head-team-invitations","parameters":[{"description":"Email address","in":"query","name":"email","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Pending invitation exists."},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"No pending invitations exists."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"No pending invitations exists."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"No pending invitations exists. (label: `not-found`)"},"409":{"content":{"application/json":{"schema":{"example":{"code":409,"label":"conflicting-invitations","message":"Multiple conflicting invitations to different teams exists."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["conflicting-invitations"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"conflicting-invitations","message":"Multiple conflicting invitations to different teams exists."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["conflicting-invitations"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Multiple conflicting invitations to different teams exists. (label: `conflicting-invitations`)"}}}},"/teams/{tid}/size":{"get":{"summary":"Get the number of team members as an integer","description":" [internal route ID: \"get-team-size\"]\n\nCan be out of sync by roughly the `refresh_interval` of the ES index.","operationId":"get-team-size","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamSize_LTMzMzk2MTk1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSize_LTMzMzk2MTk1"}}},"description":"Number of team members"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid invitation code. (label: `invalid-invitation-code`)"}}}},"/teams/invitations/accept":{"post":{"summary":"Accept a team invitation, changing a personal account into a team member account.","description":" [internal route ID: \"accept-team-invitation\"]\n\n","operationId":"accept-team-invitation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AcceptTeamInvitation_Nzg5NzI3MjA2"}}},"required":true},"responses":{"200":{"description":"Team invitation accepted."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-auth","message":"Re-authentication via password required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-auth","invalid-credentials","missing-identity","too-many-team-members"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Re-authentication via password required (label: `missing-auth`)\n\nAuthentication failed (label: `invalid-credentials`)\n\nUsing an invitation code requires registering the given email. (label: `missing-identity`)\n\nToo many members in this team. (label: `too-many-team-members`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"Invalid activation code"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code","not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)\n\nNo pending invitations exists. (label: `not-found`)"}}}},"/system/settings/unauthorized":{"get":{"summary":"Returns a curated set of system configuration settings.","description":" [internal route ID: \"get-system-settings-unauthorized\"]\n\n","operationId":"get-system-settings-unauthorized","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SystemSettingsPublic_LTgwNTMxNjU2"}}},"description":""}}}},"/system/settings":{"get":{"summary":"Returns a curated set of system configuration settings for authorized users.","description":" [internal route ID: \"get-system-settings\"]\n\n","operationId":"get-system-settings","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SystemSettings_ODU3MDk5MTA3"}}},"description":""}}}},"/oauth/clients/{OAuthClientId}":{"get":{"summary":"Get OAuth client information","description":" [internal route ID: \"get-oauth-client\"]\n\n","operationId":"get-oauth-client","parameters":[{"description":"The ID of the OAuth client","in":"path","name":"OAuthClientId","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthClient_NzExMTI5NTIy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OAuthClient_NzExMTI5NTIy"}}},"description":"OAuth client found"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"forbidden","message":"OAuth is disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"OAuth is disabled (label: `forbidden`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`OAuthClientId` or OAuth client not found (label: `not-found`)\n\nOAuth client not found (label: `not-found`)"}}}},"/oauth/authorization/codes":{"post":{"summary":"Create an OAuth authorization code","description":" [internal route ID: \"create-oauth-auth-code\"]\n\nCurrently only supports the 'code' response type, which corresponds to the authorization code flow.","operationId":"create-oauth-auth-code","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateOAuthAuthorizationCodeRequest_LTQ3NzI1Mjkz"}}},"required":true},"responses":{"201":{"description":"Created","headers":{"Location":{"schema":{"type":"string"}}}},"400":{"content":{"application/json":{"schema":{"example":{"code":400,"label":"redirect-url-miss-match","message":"The redirect URL does not match the one registered with the client"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["redirect-url-miss-match"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"redirect-url-miss-match","message":"The redirect URL does not match the one registered with the client"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["redirect-url-miss-match"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Bad Request\n\nThe redirect URL does not match the one registered with the client (label: `redirect-url-miss-match`) or `body`","headers":{"Location":{"schema":{"type":"string"}}}},"403":{"description":"Forbidden","headers":{"Location":{"schema":{"type":"string"}}}},"404":{"description":"Not Found","headers":{"Location":{"schema":{"type":"string"}}}}}}},"/oauth/token":{"post":{"summary":"Create an OAuth access token","description":" [internal route ID: \"create-oauth-access-token\"]\n\nObtain a new access token from an authorization code or a refresh token.","operationId":"create-oauth-access-token","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Either_OAuthAccessTokenRequest_OAuthRefreshAccessTokenRequest"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OAuthAccessTokenResponse_NzEwOTI4NjQ0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid_grant","message":"Invalid grant"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid_grant","forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid grant (label: `invalid_grant`)\n\nInvalid client credentials (label: `forbidden`)\n\nInvalid grant type (label: `forbidden`)\n\nInvalid refresh token (label: `forbidden`)\n\nOAuth is disabled (label: `forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"OAuth client not found (label: `not-found`)\n\nOAuth authorization code not found (label: `not-found`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"jwt-error","message":"Internal error while handling JWT token"},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["jwt-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Internal error while handling JWT token (label: `jwt-error`)"}}}},"/oauth/revoke":{"post":{"summary":"Revoke an OAuth refresh token","description":" [internal route ID: \"revoke-oauth-refresh-token\"]\n\nRevoke an access token.","operationId":"revoke-oauth-refresh-token","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OAuthRevokeRefreshTokenRequest_MjA1MDg4MzQ4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"forbidden","message":"Invalid refresh token"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid refresh token (label: `forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"OAuth client not found (label: `not-found`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"jwt-error","message":"Internal error while handling JWT token"},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["jwt-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Internal error while handling JWT token (label: `jwt-error`)"}}}},"/oauth/applications":{"get":{"summary":"Get OAuth applications with account access","description":" [internal route ID: \"get-oauth-applications\"]\n\nGet all OAuth applications with active account access for a user.","operationId":"get-oauth-applications","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/OAuthApplication_Mjk5NTUxNjA1"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/OAuthApplication_Mjk5NTUxNjA1"},"type":"array"}}},"description":"OAuth applications found"}}}},"/oauth/applications/{OAuthClientId}/sessions":{"delete":{"summary":"Revoke account access from an OAuth application","description":" [internal route ID: \"revoke-oauth-account-access\"]\n\n","operationId":"revoke-oauth-account-access","parameters":[{"description":"The ID of the OAuth client","in":"path","name":"OAuthClientId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordReqBody_LTcxMzE3ODE3"}}},"required":true},"responses":{"204":{"description":"OAuth application access revoked"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}}},"/oauth/applications/{OAuthClientId}/sessions/{RefreshTokenId}":{"delete":{"summary":"Revoke an active OAuth session","description":" [internal route ID: \"delete-oauth-refresh-token\"]\n\nRevoke an active OAuth session by providing the refresh token ID.","operationId":"delete-oauth-refresh-token","parameters":[{"description":"The ID of the OAuth client","in":"path","name":"OAuthClientId","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"The ID of the refresh token","in":"path","name":"RefreshTokenId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordReqBody_LTcxMzE3ODE3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`OAuthClientId` or `RefreshTokenId` not found\n\nOAuth client not found (label: `not-found`)"}}}},"/bot/conversations/{conv}":{"post":{"summary":"Add bot","description":" [internal route ID: \"add-bot\"]\n\n","operationId":"add-bot","parameters":[{"in":"path","name":"conv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AddBot_NjI0ODkyODk3"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddBotResponse_ODA5MzA2NTA1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AddBotResponse_ODA5MzA2NTA1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"service-disabled","message":"The desired service is currently disabled."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["service-disabled","too-many-members","invalid-conversation","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The desired service is currently disabled. (label: `service-disabled`)\n\nMaximum number of members per conversation reached. (label: `too-many-members`)\n\nThe operation is not allowed in this conversation. (label: `invalid-conversation`)\n\nAccess denied. (label: `access-denied`)"}}}},"/bot/conversations/{conv}/{bot}":{"delete":{"summary":"Remove bot","description":" [internal route ID: \"remove-bot\"]\n\n","operationId":"remove-bot","parameters":[{"in":"path","name":"conv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"bot","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveBotResponse_LTUxNTQ4MDEy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RemoveBotResponse_LTUxNTQ4MDEy"}}},"description":"User found"},"204":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-conversation","message":"The operation is not allowed in this conversation."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-conversation","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The operation is not allowed in this conversation. (label: `invalid-conversation`)\n\nAccess denied. (label: `access-denied`)"}}}},"/bot/self":{"get":{"summary":"Get self","description":" [internal route ID: \"bot-get-self\"]\n\n","operationId":"bot-get-self","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"User not found (label: `not-found`)"}}},"delete":{"summary":"Delete self","description":" [internal route ID: \"bot-delete-self\"]\n\n","operationId":"bot-delete-self","responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-bot","message":"The targeted user is not a bot."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-bot","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The targeted user is not a bot. (label: `invalid-bot`)\n\nAccess denied. (label: `access-denied`)"}}}},"/bot/client/prekeys":{"get":{"summary":"List prekeys for bot","description":" [internal route ID: \"bot-list-prekeys\"]\n\n","operationId":"bot-list-prekeys","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"maximum":65535,"minimum":0,"type":"integer"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}},"post":{"summary":"Update prekeys for bot","description":" [internal route ID: \"bot-update-prekeys\"]\n\n","operationId":"bot-update-prekeys","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateBotPrekeys_LTg3NzYxODg0"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"client-not-found","message":"Client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["client-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Client not found (label: `client-not-found`)"}}}},"/bot/client":{"get":{"summary":"Get client for bot","description":" [internal route ID: \"bot-get-client\"]\n\n","operationId":"bot-get-client","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}}},"description":"Client found"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"client-not-found","message":"Client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["client-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"client-not-found","message":"Client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["client-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Client not found (label: `client-not-found`)\n\nClient not found (label: `client-not-found`)"}}}},"/bot/users/prekeys":{"post":{"summary":"Claim users prekeys","description":" [internal route ID: \"bot-claim-users-prekeys\"]\n\n","operationId":"bot-claim-users-prekeys","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserClients"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserClientPrekeyMap"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","missing-legalhold-consent-old-clients","too-many-clients","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nToo many clients (label: `too-many-clients`)\n\nAccess denied. (label: `access-denied`)"}}}},"/bot/users":{"get":{"summary":"List users","description":" [internal route ID: \"bot-list-users\"]\n\n","operationId":"bot-list-users","parameters":[{"in":"query","name":"ids","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/BotUserView_LTE2MTkwMTcw"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}}},"/bot/users/{user}/clients":{"get":{"summary":"Get user clients","description":" [internal route ID: \"bot-get-user-clients\"]\n\n","operationId":"bot-get-user-clients","parameters":[{"in":"path","name":"user","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/PubClient"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}}},"/provider/services":{"get":{"summary":"List provider services","description":" [internal route ID: \"get-provider-services\"]\n\n","operationId":"get-provider-services","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Service_MjcyOTA5NjQx"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}},"post":{"summary":"Create a new service","description":" [internal route ID: \"post-provider-services\"]\n\n","operationId":"post-provider-services","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewService_LTYwOTU1MDQ3"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewServiceResponse_LTExMzcwMjg5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewServiceResponse_LTExMzcwMjg5"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-service-key","message":"Invalid service key."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-service-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid service key. (label: `invalid-service-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}}},"/provider/services/{service-id}":{"get":{"summary":"Get provider service by service id","description":" [internal route ID: \"get-provider-services-by-service-id\"]\n\n","operationId":"get-provider-services-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Service_MjcyOTA5NjQx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nService not found. (label: `not-found`)"}}},"put":{"summary":"Update provider service","description":" [internal route ID: \"put-provider-services-by-service-id\"]\n\n","operationId":"put-provider-services-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateService_MjAxNzQ2Njkz"}}},"required":true},"responses":{"200":{"description":"Provider service updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nProvider not found. (label: `not-found`)\n\nService not found. (label: `not-found`)"}}},"delete":{"summary":"Delete service","description":" [internal route ID: \"delete-provider-services-by-service-id\"]\n\n","operationId":"delete-provider-services-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteService_LTY2NzY5NzMz"}}},"required":true},"responses":{"202":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nService not found. (label: `not-found`)"}}}},"/provider/services/{service-id}/connection":{"put":{"summary":"Update provider service connection","description":" [internal route ID: \"put-provider-services-connection-by-service-id\"]\n\n","operationId":"put-provider-services-connection-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateServiceConn_LTQ1OTYwNjIz"}}},"required":true},"responses":{"200":{"description":"Provider service connection updated"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-service-key","message":"Invalid service key."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-service-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid service key. (label: `invalid-service-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nService not found. (label: `not-found`)"}}}},"/providers/{provider-id}/services":{"get":{"summary":"Get provider services by provider id","description":" [internal route ID: \"get-provider-services-by-provider-id\"]\n\n","operationId":"get-provider-services-by-provider-id","parameters":[{"in":"path","name":"provider-id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/ServiceProfile_LTc2MDQzNTk3"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}}},"/services":{"get":{"summary":"List services","description":" [internal route ID: \"get-services\"]\n\n","operationId":"get-services","parameters":[{"in":"query","name":"tags","required":false,"schema":{"enum":["audio","books","business","design","education","entertainment","finance","fitness","food-drink","games","graphics","health","integration","lifestyle","media","medical","movies","music","news","photography","poll","productivity","quiz","rating","shopping","social","sports","travel","tutorial","video","weather"],"type":"string"}},{"in":"query","name":"start","required":false,"schema":{"type":"string"}},{"in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":100,"minimum":10,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceProfilePage_Njg1NDQ5Njc4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}}},"/services/tags":{"get":{"summary":"Get services tags","description":" [internal route ID: \"get-services-tags\"]\n\n","operationId":"get-services-tags","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceTagList"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}}},"/providers/{provider-id}/services/{service-id}":{"get":{"summary":"Get provider service by provider id and service id","description":" [internal route ID: \"get-provider-services-by-provider-id-and-service-id\"]\n\n","operationId":"get-provider-services-by-provider-id-and-service-id","parameters":[{"in":"path","name":"provider-id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceProfile_LTc2MDQzNTk3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`provider-id` or `service-id` not found\n\nService not found. (label: `not-found`)"}}}},"/teams/{team-id}/services/whitelisted":{"get":{"summary":"Get whitelisted services by team id","description":" [internal route ID: \"get-whitelisted-services-by-team-id\"]\n\n","operationId":"get-whitelisted-services-by-team-id","parameters":[{"in":"path","name":"team-id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"prefix","required":false,"schema":{"maxLength":128,"minLength":1,"type":"string"}},{"in":"query","name":"filter_disabled","required":false,"schema":{"type":"boolean"}},{"in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":100,"minimum":10,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceProfilePage_Njg1NDQ5Njc4"}}},"description":""}}}},"/teams/{team-id}/services/whitelist":{"post":{"summary":"Update service whitelist","description":" [internal route ID: \"post-team-whitelist-by-team-id\"]\n\n","operationId":"post-team-whitelist-by-team-id","parameters":[{"in":"path","name":"team-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateServiceWhitelist_LTU5MDAwMTIw"}}},"required":true},"responses":{"200":{"description":"UpdateServiceWhitelistRespChanged"},"204":{"description":"UpdateServiceWhitelistRespUnchanged"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-services-not-allowed","message":"Services not allowed in MLS"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-services-not-allowed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Services not allowed in MLS (label: `mls-services-not-allowed`)"}}}},"/provider/register":{"post":{"summary":"Register a new provider","description":" [internal route ID: \"provider-register\"]\n\n","operationId":"provider-register","parameters":[{"in":"header","name":"X-Forwarded-For","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewProvider_LTEyMTY5MjYy"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewProviderResponse_OTE0ODI2NjU0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewProviderResponse_OTE0ODI2NjU0"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `X-Forwarded-For`\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Too many request to generate a verification code."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many request to generate a verification code. (label: `too-many-requests`)"}}}},"/provider/activate":{"get":{"summary":"Activate a provider","description":" [internal route ID: \"provider-activate\"]\n\n","operationId":"provider-activate","parameters":[{"in":"query","name":"key","required":true,"schema":{"type":"string"}},{"in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderActivationResponse_LTgzNTU3MzA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ProviderActivationResponse_LTgzNTU3MzA5"}}},"description":""},"204":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-code","message":"Invalid verification code"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-code","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid verification code (label: `invalid-code`)\n\nAccess denied. (label: `access-denied`)"}}}},"/provider/login":{"post":{"summary":"Login as a provider","description":" [internal route ID: \"provider-login\"]\n\n","operationId":"provider-login","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ProviderLogin_LTE2MTk2NTM5"}}},"required":true},"responses":{"200":{"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"}}}},"/provider/password-reset":{"post":{"summary":"Begin a password reset","description":" [internal route ID: \"provider-password-reset\"]\n\n","operationId":"provider-password-reset","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordReset_LTYzNDYxNTQ3"}}},"required":true},"responses":{"201":{"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-code","message":"Invalid password reset code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-code","invalid-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)\n\nInvalid email or mobile number for password reset. (label: `invalid-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password reset, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ","code-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password reset, new and old password must be different. (label: `password-must-differ`)\n\nA password reset is already in progress. (label: `code-exists`)\n\nA password reset is already in progress. (label: `code-exists`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Too many request to generate a verification code."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many request to generate a verification code. (label: `too-many-requests`)"}}}},"/provider/password-reset/complete":{"post":{"summary":"Complete a password reset","description":" [internal route ID: \"provider-password-reset-complete\"]\n\n","operationId":"provider-password-reset-complete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CompletePasswordReset_LTYzMDAxNDA1"}}},"required":true},"responses":{"200":{"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-code","message":"Invalid password reset code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","invalid-code","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nInvalid verification code (label: `invalid-code`)\n\nAccess denied. (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password reset, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password reset, new and old password must be different. (label: `password-must-differ`)"}}}},"/provider":{"get":{"summary":"Get account","description":" [internal route ID: \"provider-get-account\"]\n\n","operationId":"provider-get-account","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Provider not found. (label: `not-found`)\n\nProvider not found. (label: `not-found`)"}}},"put":{"summary":"Update a provider","description":" [internal route ID: \"provider-update\"]\n\n","operationId":"provider-update","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateProvider_LTQwMjY4MDgy"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-provider","message":"The provider does not exist."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-provider","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)"}}},"delete":{"summary":"Delete a provider","description":" [internal route ID: \"provider-delete\"]\n\n","operationId":"provider-delete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteProvider_MzYxMzM3Mjg2"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","invalid-provider","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nThe provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)"}}}},"/provider/email":{"put":{"summary":"Update a provider email","description":" [internal route ID: \"provider-update-email\"]\n\n","operationId":"provider-update-email","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/EmailUpdate_LTYwODE0ODQ5"}}},"required":true},"responses":{"202":{"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-provider","message":"The provider does not exist."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-provider","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Too many request to generate a verification code."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many request to generate a verification code. (label: `too-many-requests`)"}}}},"/provider/password":{"put":{"summary":"Update a provider password","description":" [internal route ID: \"provider-update-password\"]\n\n","operationId":"provider-update-password","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordChange_NDI0ODgwNDU0"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password reset, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password reset, new and old password must be different. (label: `password-must-differ`)"}}}},"/providers/{pid}":{"get":{"summary":"Get profile","description":" [internal route ID: \"provider-get-profile\"]\n\n","operationId":"provider-get-profile","parameters":[{"in":"path","name":"pid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}}},"description":""},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`pid` or Provider not found. (label: `not-found`)"}}}},"/domain-verification/{domain}/backend":{"post":{"summary":"Update the domain redirect configuration","description":" [internal route ID: \"update-domain-redirect\"]\n\n","operationId":"update-domain-redirect","parameters":[{"in":"header","name":"Authorization","required":true,"schema":{"type":"string"}},{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainRedirectConfig_NTI5NDE5MDQy"}}},"required":true},"responses":{"200":{"description":"Updated"},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}}}},"/get-domain-registration":{"post":{"summary":"Get domain registration configuration by email","description":" [internal route ID: \"get-domain-registration\"]\n\n- `due_to_existing_account`: boolean (optional, only present if `domain_redirect` is `no-registration`)\n- `backend`: object (optional, must be present if `domain_redirect` is `backend`)\n - `config_url`: string (required)\n - `webapp_url`: string (optional)\n- `sso_code`: string (optional, must be present if `domain_redirect` is `sso`)","operationId":"get-domain-registration","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetDomainRegistrationRequest_LTg4NTM1MzM2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainRedirectResponse_V10_LTEyMjI4NTM0"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-domain","message":"Invalid domain"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-domain"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid domain (label: `invalid-domain`)"}}}},"/domain-verification/{domain}/team/challenges/{challengeId}":{"post":{"summary":"Verify a DNS verification challenge for a team","description":" [internal route ID: \"verify-challenge-team\"]\n\n","operationId":"verify-challenge-team","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"challengeId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ChallengeToken_Mzk3NTcwOTM3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainOwnershipToken_NTU0ODc1NDE5"}}},"description":""},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}}}},"/domain-verification/{domain}/authorize-team":{"post":{"summary":"Authorize a team to operate on a verified domain","description":" [internal route ID: \"domain-verification-authorize-team\"]\n\n","operationId":"domain-verification-authorize-team","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainOwnershipToken_NTU0ODc1NDE5"}}},"required":true},"responses":{"200":{"description":"Authorized"},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}}}},"/domain-verification/{domain}/team":{"post":{"summary":"Update the team-invite configuration","description":" [internal route ID: \"update-team-invite\"]\n\n","operationId":"update-team-invite","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamInviteConfig_MTg4Nzk4NzMz"}}},"required":true},"responses":{"200":{"description":"Updated"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}}}},"/teams/{teamId}/registered-domains":{"get":{"summary":"Get all registered domains","description":" [internal route ID: \"get-all-registered-domains\"]\n\n","operationId":"get-all-registered-domains","parameters":[{"in":"path","name":"teamId","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RegisteredDomains_V10_NDYwNzYyMTMy"}}},"description":""}}}},"/teams/{teamId}/registered-domains/{domain}":{"delete":{"summary":"Delete a registered domain","description":" [internal route ID: \"delete-registered-domain\"]\n\n","operationId":"delete-registered-domain","parameters":[{"in":"path","name":"teamId","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}}}},"/domain-verification/{domain}/challenges":{"post":{"summary":"Get a DNS verification challenge","description":" [internal route ID: \"domain-verification-challenge\"]\n\n","operationId":"domain-verification-challenge","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainVerificationChallenge_NjIwMzA1MjE5"}}},"description":""}}}},"/domain-verification/{domain}/challenges/{challengeId}":{"post":{"summary":"Verify a DNS verification challenge","description":" [internal route ID: \"verify-challenge\"]\n\n","operationId":"verify-challenge","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"challengeId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ChallengeToken_Mzk3NTcwOTM3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainOwnershipToken_NTU0ODc1NDE5"}}},"description":""},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"domain-verification-failed","message":"Domain verification failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["domain-verification-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain verification failed (label: `domain-verification-failed`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"challenge-not-found","message":"Challenge not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["challenge-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `challengeId` not found\n\nChallenge not found (label: `challenge-not-found`)"}}}},"/user-groups":{"get":{"summary":"Fetch groups accessible to the logged-in user","description":" [internal route ID: \"get-user-groups\"]\n\n","operationId":"get-user-groups","parameters":[{"description":"Search string","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"in":"query","name":"sort_by","required":false,"schema":{"enum":["name","created_at"],"type":"string"}},{"in":"query","name":"sort_order","required":false,"schema":{"enum":["asc","desc"],"type":"string"}},{"in":"query","name":"page_size","required":false,"schema":{"description":"integer from [1..500]","type":"number"}},{"description":"`name` of the last seen user group, used to get the next page when sorting by name.","in":"query","name":"last_seen_name","required":false,"schema":{"maxLength":4000,"minLength":1,"type":"string"}},{"description":"`created_at` field of the last seen user group, used to get the next page when sorting by created_at.","in":"query","name":"last_seen_created_at","required":false,"schema":{"format":"yyyy-mm-ddThh:MM:ssZ","type":"string"}},{"description":"`id` of the last seen group, used to get the next page. **Must** be sent to get the next page.","in":"query","name":"last_seen_id","required":false,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"include_channels","schema":{"default":false,"type":"boolean"}},{"allowEmptyValue":true,"in":"query","name":"include_member_count","schema":{"default":false,"type":"boolean"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupPage_UserGroup_Const_LTMxNDg5MDAy"}}},"description":""}}},"post":{"description":" [internal route ID: \"create-user-group\"]\n\n","operationId":"create-user-group","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewUserGroup_MzYxODU0OTU1"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nOnly team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"}}}},"/user-groups/{gid}":{"get":{"summary":"Fetch a group accessible to the logged-in user","description":" [internal route ID: \"get-user-group\"]\n\n","operationId":"get-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"include_channels","schema":{"default":false,"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}}},"description":"User Group Found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or User group not found (label: `user-group-not-found`)\n\nUser group not found (label: `user-group-not-found`)"}}},"put":{"description":" [internal route ID: \"update-user-group\"]\n\n","operationId":"update-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupUpdate_MjUyNTA3Mjgy"}}},"required":true},"responses":{"200":{"description":"User added updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}},"delete":{"description":" [internal route ID: \"delete-user-group\"]\n\n","operationId":"delete-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User group deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}}},"/user-groups/{gid}/users/{uid}":{"post":{"description":" [internal route ID: \"add-user-to-group\"]\n\n","operationId":"add-user-to-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User added to group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or `uid` not found\n\nUser group not found (label: `user-group-not-found`)"}}},"delete":{"description":" [internal route ID: \"remove-user-from-group\"]\n\n","operationId":"remove-user-from-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User removed from group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or `uid` not found\n\nUser group not found (label: `user-group-not-found`)"}}}},"/user-groups/{gid}/users":{"put":{"summary":"[STUB] Update user group members. Replaces the users with the given list.","description":" [internal route ID: \"update-user-group-members\"]\n\n","operationId":"update-user-group-members","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateUserGroupMembers_LTg1MzQ2NDY3"}}},"required":true},"responses":{"200":{"description":"User group members updated"}}},"post":{"description":" [internal route ID: \"add-users-to-group-bulk\"]\n\n","operationId":"add-users-to-group-bulk","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupAddUsers_LTgzOTYzNzk0"}}},"required":true},"responses":{"204":{"description":"Users added to group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nOnly team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}}},"/user-groups/{gid}/channels":{"put":{"summary":"Replaces the channels with the given list.","description":" [internal route ID: \"update-user-group-channels\"]\n\n","operationId":"update-user-group-channels","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"append_only","schema":{"default":false,"type":"boolean"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateUserGroupChannels_LTIyMjcwMTMx"}}},"required":true},"responses":{"200":{"description":"User group channels updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}}},"/user-groups/check-name":{"post":{"summary":"[STUB] Check if a user group name is available","description":" [internal route ID: \"check-user-group-name-available\"]\n\n","operationId":"check-user-group-name-available","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CheckUserGroupName_LTg0ODU1OTk1"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserGroupNameAvailability_LTYzMDE1NTk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupNameAvailability_LTYzMDE1NTk4"}}},"description":"OK"}}}},"/teams/{tid}/apps":{"get":{"summary":"Get all apps owned by the given team (not including collaborators)","description":" [internal route ID: \"get-apps\"]\n\n","operationId":"get-apps","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"},"type":"array"}}},"description":""}}},"post":{"summary":"Create a new app","description":" [internal route ID: \"create-app\"]\n\n","operationId":"create-app","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewApp_LTQwODMwMzQ4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreatedApp_LTM3NjUxOTY1"}}},"description":""}}}},"/teams/{tid}/apps/{app}":{"put":{"summary":"Update metadata of an existing app","description":" [internal route ID: \"put-app\"]\n\n","operationId":"put-app","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"app","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PutApp_LTE4MDc1OTM4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""}}}},"/teams/{tid}/apps/{app}/cookies":{"post":{"summary":"Get a new app authentication token","description":" [internal route ID: \"refresh-app-cookie\"]\n\n","operationId":"refresh-app-cookie","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"app","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RefreshAppCookieRequest_MjEyMDMyMTk5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RefreshAppCookieResponse_LTQ0MjU1NTIw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-auth","message":"Re-authentication via password required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-auth"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Re-authentication via password required (label: `missing-auth`)"}}}},"/conversations/{cnv_domain}/{cnv}":{"get":{"summary":"Get a conversation by ID","description":" [internal route ID: \"get-conversation\"]\n\n","operationId":"get-conversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Conversation_GroupConvType_MzQzMTQ1OTg3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv}/roles":{"get":{"summary":"Get existing roles available for the given conversation","description":" [internal route ID: \"get-conversation-roles\"]\n\n","operationId":"get-conversation-roles","parameters":[{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationRolesList"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/groupinfo":{"get":{"summary":"Get MLS group information","description":" [internal route ID: \"get-group-info\"]\n\n","operationId":"get-group-info","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/GroupInfoData"}}},"description":"The group information"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-missing-group-info","message":"The conversation has no group information"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-missing-group-info","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nThe conversation has no group information (label: `mls-missing-group-info`)\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/list-ids":{"post":{"summary":"Get all conversation IDs.","description":" [internal route ID: \"list-conversation-ids\"]\n\nThe IDs returned by this endpoint are paginated. To get the first page, make a call with the `paging_state` field set to `null` (or omitted). Whenever the `has_more` field of the response is set to `true`, more results are available, and they can be obtained by calling the endpoint again, but this time passing the value of `paging_state` returned by the previous call. One can continue in this fashion until all results are returned, which is indicated by `has_more` being `false`. Note that `paging_state` should be considered an opaque token. It should not be inspected, or stored, or reused across multiple unrelated invocations of the endpoint.","operationId":"list-conversation-ids","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetMuliTabPgRqs_ConvrIdLcOm10_ODU0NjQ1NDMz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MultiTabePg_ConvrsIdqf_cLORm_Q_Mzg0MjYzNzM0"}}},"description":""}}}},"/conversations/list":{"post":{"summary":"Get conversation metadata for a list of conversation ids","description":" [internal route ID: \"list-conversations\"]\n\n","operationId":"list-conversations","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ListConversations_MjkxMTIwODMz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationsResponse_GroupConvType_ODkxMjM2ODM0"}}},"description":""}}}},"/conversations/join":{"get":{"summary":"Get limited conversation information by key/code pair","description":" [internal route ID: \"get-conversation-by-reusable-code\"]\n\n","operationId":"get-conversation-by-reusable-code","parameters":[{"in":"query","name":"key","required":true,"schema":{"type":"string"}},{"in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCoverView_LTMwNDkxMTA1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","access-denied","invalid-conversation-password"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid conversation password (label: `invalid-conversation-password`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"guest-links-disabled","message":"The guest link feature is disabled and all guest links have been revoked"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}}},"post":{"summary":"Join a conversation using a reusable code","description":" [internal route ID: \"join-conversation-by-code-unqualified\"]\n\nIf the guest links team feature is disabled, this will fail with 409 GuestLinksDisabled.Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/code-check` which responds with 404 CodeNotFound if guest links are disabled.","operationId":"join-conversation-by-code-unqualified","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/JoinConversationByCode_NjgzMzM4Mjg5"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation joined"},"204":{"description":"Conversation unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"too-many-members","message":"Maximum number of members per conversation reached"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["too-many-members","no-team-member","invalid-op","access-denied","invalid-conversation-password"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Maximum number of members per conversation reached (label: `too-many-members`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid conversation password (label: `invalid-conversation-password`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"guest-links-disabled","message":"The guest link feature is disabled and all guest links have been revoked"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}}}},"/conversations":{"post":{"summary":"Create a new conversation","description":" [internal route ID: \"create-group-conversation\"]\n\nThis returns 201 when a new conversation is created, and 200 when the conversation already existed\nOAuth scope: `write:conversations`","operationId":"create-group-conversation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewConv_LTgzNTk1NDQx"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGroupConversation_GroupConvType_MTQ3OTc4NDk3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateGroupConversation_GroupConvType_MTQ3OTc4NDk3"}}},"description":"Conversation created","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"history-not-supported","message":"Shared history is not supported on this conversation"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["history-not-supported","mls-not-enabled","non-empty-member-list"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nShared history is not supported on this conversation (label: `history-not-supported`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nAttempting to add group members outside MLS (label: `non-empty-member-list`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"channels-not-enabled","message":"The channels feature is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["channels-not-enabled","not-mls-conversation","missing-legalhold-consent","operation-denied","no-team-member","not-connected","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The channels feature is not enabled for this team (label: `channels-not-enabled`)\n\nThis operation requires an MLS conversation (label: `not-mls-conversation`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nUsers are not connected (label: `not-connected`)\n\nConversation access denied (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}}},"/conversations/self":{"post":{"summary":"Create a self-conversation","description":" [internal route ID: \"create-self-conversation\"]\n\n","operationId":"create-self-conversation","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_GroupConvType_LTU2MzYxNTg0V6"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_GroupConvType_LTU2MzYxNTg0V6"}}},"description":"Conversation existed","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_GroupConvType_LTU2MzYxNTg0V6"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_GroupConvType_LTU2MzYxNTg0V6"}}},"description":"Conversation created","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}}}}},"/conversations/mls-self":{"get":{"summary":"Get the user's MLS self-conversation","description":" [internal route ID: \"get-mls-self-conversation\"]\n\n","operationId":"get-mls-self-conversation","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v9_OwnConversation_GroupConvType_LTU2MzYxNTg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v9_OwnConversation_GroupConvType_LTU2MzYxNTg0"}}},"description":"The MLS self-conversation"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"}}}},"/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}":{"get":{"summary":"Get information about an MLS subconversation","description":" [internal route ID: \"get-subconversation\"]\n\n","operationId":"get-subconversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicSubConversation_MjI2NTIxMzU4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PublicSubConversation_MjI2NTIxMzU4"}}},"description":"Subconversation"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-subconv-unsupported-convtype","message":"MLS subconversations are only supported for regular conversations"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-subconv-unsupported-convtype","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS subconversations are only supported for regular conversations (label: `mls-subconv-unsupported-convtype`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)"}}},"delete":{"summary":"Delete an MLS subconversation","description":" [internal route ID: \"delete-subconversation\"]\n\n","operationId":"delete-subconversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSReset_NzgwODA3ODc4"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"Deletion successful"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-stale-message","message":"The conversation epoch in a message is too old"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-stale-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The conversation epoch in a message is too old (label: `mls-stale-message`)"}}}},"/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}/self":{"delete":{"summary":"Leave an MLS subconversation","description":" [internal route ID: \"leave-subconversation\"]\n\n","operationId":"leave-subconversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled","mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nMLS protocol error (label: `mls-protocol-error`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-stale-message","message":"The conversation epoch in a message is too old"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-stale-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The conversation epoch in a message is too old (label: `mls-stale-message`)"}}}},"/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}/groupinfo":{"get":{"summary":"Get MLS group information of subconversation","description":" [internal route ID: \"get-subconversation-group-info\"]\n\n","operationId":"get-subconversation-group-info","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/GroupInfoData"}}},"description":"The group information"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-missing-group-info","message":"The conversation has no group information"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-missing-group-info","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nThe conversation has no group information (label: `mls-missing-group-info`)\n\nConversation not found (label: `no-conversation`)"}}}},"/one2one-conversations":{"post":{"summary":"Create a 1:1 conversation","description":" [internal route ID: \"create-one-to-one-conversation\"]\n\n","operationId":"create-one-to-one-conversation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOne2OneConv_LTI3OTc4NDAz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_GroupConvType_LTU2MzYxNTg0V3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_GroupConvType_LTU2MzYxNTg0V3"}}},"description":"Conversation existed","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_GroupConvType_LTU2MzYxNTg0V3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_GroupConvType_LTU2MzYxNTg0V3"}}},"description":"Conversation created","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","operation-denied","not-connected","no-team-member","non-binding-team-members","invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nInsufficient permissions (label: `operation-denied`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nBoth users must be members of the same binding team (label: `non-binding-team-members`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","non-binding-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)\n\nNot a member of a binding team (label: `non-binding-team`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}}},"/one2one-conversations/{usr_domain}/{usr}":{"get":{"summary":"Get an MLS 1:1 conversation","description":" [internal route ID: \"get-one-to-one-mls-conversation\"]\n\n","operationId":"get-one-to-one-mls-conversation","parameters":[{"in":"path","name":"usr_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"usr","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"format","required":false,"schema":{"enum":["raw","jwk"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSOne2OneConversation_SomeKey_GroupConvType_LTg2MTcwMjY3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSOne2OneConversation_SomeKey_GroupConvType_LTg2MTcwMjY3"}}},"description":"MLS 1-1 conversation"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `format`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"not-connected","message":"Users are not connected"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["not-connected"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Users are not connected (label: `not-connected`)"}}}},"/conversations/{cnv_domain}/{cnv}/members":{"put":{"summary":"Replace the members of a conversation.","description":" [internal route ID: \"replace-members-in-conversation\"]\n\nThis will add any members not already in the conversation, and remove any members not in the provided list except users that are associated via a user group. The given role in the request body will be applied to all added members. The roles of already existing members will not be changed even if these members are included in the request body and their role differs from the role provided in this request.","operationId":"replace-members-in-conversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InviteQualified_ODYyODIyNjYz"}}},"required":true},"responses":{"200":{"description":"Conversation members replaced"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-group-id-not-supported","message":"The group ID version of the conversation is not supported by one of the federated backends"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-group-id-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","not-connected","no-team-member","access-denied","too-many-members","invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nMaximum number of members per conversation reached (label: `too-many-members`)\n\nInvalid operation (label: `invalid-op`)\n\nThe conversation would be left without an admin\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nInsufficient authorization (missing add_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}},"post":{"summary":"Add qualified members to an existing conversation.","description":" [internal route ID: \"add-members-to-conversation\"]\n\n","operationId":"add-members-to-conversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InviteQualified_ODYyODIyNjYz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation updated"},"204":{"description":"Conversation unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-group-id-not-supported","message":"The group ID version of the conversation is not supported by one of the federated backends"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-group-id-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","not-connected","no-team-member","access-denied","too-many-members","invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nMaximum number of members per conversation reached (label: `too-many-members`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInsufficient authorization (missing add_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}}},"/conversations/code-check":{"post":{"summary":"Check validity of a conversation code.","description":" [internal route ID: \"code-check\"]\n\nIf the guest links team feature is disabled, this will fail with 404 CodeNotFound.Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/join` which responds with 409 GuestLinksDisabled if guest links are disabled.","operationId":"code-check","parameters":[{"in":"header","name":"X-Forwarded-For","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCode_Mjg3OTI1NTMx"}}},"required":true},"responses":{"200":{"description":"Valid"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-conversation-password","message":"Invalid conversation password"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-conversation-password"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid conversation password (label: `invalid-conversation-password`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"}}}},"/conversations/{cnv}/code":{"get":{"summary":"Get existing conversation code","description":" [internal route ID: \"get-code\"]\n\n\nOAuth scope: `write:conversations_code`","operationId":"get-code","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}}},"description":"Conversation Code"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"guest-links-disabled","message":"The guest link feature is disabled and all guest links have been revoked"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}}},"post":{"summary":"Create or recreate a conversation code","description":" [internal route ID: \"create-conversation-code-unqualified\"]\n\n\nOAuth scope: `write:conversations_code`","operationId":"create-conversation-code-unqualified","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateConversationCodeRequest_NTYzMTA1NDYz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}}},"description":"Conversation code already exists."},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation code created."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"create-conv-code-conflict","message":"Conversation code already exists with a different password setting than the requested one."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["create-conv-code-conflict","guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation code already exists with a different password setting than the requested one. (label: `create-conv-code-conflict`)\n\nThe guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}}},"delete":{"summary":"Delete conversation code","description":" [internal route ID: \"remove-code-unqualified\"]\n\n","operationId":"remove-code-unqualified","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation code deleted."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv}/features/conversationGuestLinks":{"get":{"summary":"Get the status of the guest links feature for a conversation that potentially has been created by someone from another team.","description":" [internal route ID: \"get-conversation-guest-links-status\"]\n\n","operationId":"get-conversation-guest-links-status","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/typing":{"post":{"summary":"Sending typing notifications","description":" [internal route ID: \"member-typing-qualified\"]\n\n","operationId":"member-typing-qualified","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TypingStatus_LTg5MzcyNDMy"}}},"required":true},"responses":{"200":{"description":"Notification sent"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}":{"put":{"summary":"Update membership of the specified user","description":" [internal route ID: \"update-other-member\"]\n\n**Note**: at least one field has to be provided.","operationId":"update-other-member","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"usr_domain","required":true,"schema":{"type":"string"}},{"description":"Target User ID","in":"path","name":"usr","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OtherMemberUpdate_LTM1MjYzOTU0"}}},"required":true},"responses":{"200":{"description":"Membership updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nInvalid target (label: `invalid-op`)\n\nInsufficient authorization (missing modify_other_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation-member","message":"Conversation member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation-member","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `usr_domain` or `usr` not found\n\nConversation member not found (label: `no-conversation-member`)\n\nConversation not found (label: `no-conversation`)"}}},"delete":{"summary":"Remove a member from a conversation","description":" [internal route ID: \"remove-member\"]\n\n","operationId":"remove-member","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"usr_domain","required":true,"schema":{"type":"string"}},{"description":"Target User ID","in":"path","name":"usr","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Member removed"},"204":{"description":"No change"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"eligible_members":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["eligible_members"],"type":"object"}}},"description":"The conversation would be left without an admin\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `usr_domain` or `usr` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/name":{"put":{"summary":"Update conversation name","description":" [internal route ID: \"update-conversation-name\"]\n\n","operationId":"update-conversation-name","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationRename_ODkwODg1MzQ0"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Name unchanged"},"204":{"description":"Name updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_conversation_name) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/message-timer":{"put":{"summary":"Update the message timer for a conversation","description":" [internal route ID: \"update-conversation-message-timer\"]\n\n","operationId":"update-conversation-message-timer","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationMessageTimerUpdate_LTcxMjUwNzQ4"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Message timer updated"},"204":{"description":"Message timer unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_message_timer) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/receipt-mode":{"put":{"summary":"Update receipt mode for a conversation","description":" [internal route ID: \"update-conversation-receipt-mode\"]\n\n","operationId":"update-conversation-receipt-mode","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationReceiptModeUpdate_NDE4MzUzNTU3"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Receipt mode updated"},"204":{"description":"Receipt mode unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-receipts-not-allowed","message":"Read receipts on MLS conversations are not allowed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-receipts-not-allowed","invalid-op","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Read receipts on MLS conversations are not allowed (label: `mls-receipts-not-allowed`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_receipt_mode) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/access":{"put":{"summary":"Update access modes for a conversation","description":" [internal route ID: \"update-conversation-access\"]\n\n","operationId":"update-conversation-access","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationAccessData_MjMxMTI5ODc3"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Access updated"},"204":{"description":"Access unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid target access"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid target access (label: `invalid-op`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nInsufficient authorization (missing modify_conversation_access) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/history":{"put":{"summary":"Update history settings of a conversation","description":" [internal route ID: \"update-conversation-history\"]\n\n","operationId":"update-conversation-history","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationHistoryUpdate_LTg5MDQ5Nzgx"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"History updated"},"204":{"description":"History unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"history-not-supported","message":"Shared history is not supported on this conversation"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["history-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nShared history is not supported on this conversation (label: `history-not-supported`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"action-denied","message":"Insufficient authorization (missing modify_conversation_access)"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["action-denied","invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient authorization (missing modify_conversation_access) (label: `action-denied`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/self":{"get":{"summary":"Get self membership properties","description":" [internal route ID: \"get-conversation-self\"]\n\n","operationId":"get-conversation-self","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Member_OTA5OTgyNzcw"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}},"put":{"summary":"Update self membership properties","description":" [internal route ID: \"update-conversation-self\"]\n\n**Note**: at least one field has to be provided.","operationId":"update-conversation-self","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MemberUpdate_LTg4NTQ0OTYz"}}},"required":true},"responses":{"200":{"description":"Update successful"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/protocol":{"put":{"summary":"Update the protocol of the conversation","description":" [internal route ID: \"update-conversation-protocol\"]\n\n**Note**: Only proteus->mixed upgrade is supported.","operationId":"update-conversation-protocol","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ProtocolUpdate_NzY1ODgxNDQy"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation updated"},"204":{"description":"Conversation unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-migration-criteria-not-satisfied","message":"The migration criteria for mixed to MLS protocol transition are not satisfied for this conversation"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-migration-criteria-not-satisfied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nThe migration criteria for mixed to MLS protocol transition are not satisfied for this conversation (label: `mls-migration-criteria-not-satisfied`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","invalid-op","action-denied","invalid-protocol-transition"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nProtocol transition is invalid (label: `invalid-protocol-transition`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nTeam not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/add-permission":{"put":{"summary":"Update the permissions for adding members to a channel","description":" [internal route ID: \"update-channel-add-permission\"]\n\n","operationId":"update-channel-add-permission","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AddPermissionUpdate_LTU3MzEwOTY4"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Add permissions updated"},"204":{"description":"Add permissions unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid target access"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","not-connected","operation-denied","no-team-member","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid target access (label: `invalid-op`)\n\nUsers are not connected (label: `not-connected`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_add_permissions) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nTeam not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}}},"/teams/{tid}/conversations/roles":{"get":{"summary":"Get existing roles available for the given team","description":" [internal route ID: \"get-team-conversation-roles\"]\n\n","operationId":"get-team-conversation-roles","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationRolesList"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"}}}},"/teams/{tid}/conversations":{"get":{"summary":"Get team conversations","description":" [internal route ID: \"get-team-conversations\"]\n\n","operationId":"get-team-conversations","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamConversationList_OTI3MzY3NzY0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"}}}},"/teams/{tid}/conversations/{cid}":{"get":{"summary":"Get one team conversation","description":" [internal route ID: \"get-team-conversation\"]\n\n","operationId":"get-team-conversation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"cid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamConversation_LTIwNzgyNTEz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `cid` not found\n\nConversation not found (label: `no-conversation`)"}}},"delete":{"summary":"Remove a team conversation","description":" [internal route ID: \"delete-team-conversation\"]\n\n","operationId":"delete-team-conversation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"cid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"Conversation deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing delete_conversation) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `cid` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv}/otr/messages":{"post":{"summary":"Post an encrypted message to a conversation (accepts JSON or Protobuf)","description":" [internal route ID: \"post-otr-message-unqualified\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts two query params:\n- `ignore_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are ignored.\n - When 'false' all missing clients are reported.\n - When comma separated list of user-ids, only clients for listed users are ignored.\n- `report_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are reported.\n - When 'false' all missing clients are ignored.\n - When comma separated list of user-ids, only clients for listed users are reported.\n\nApart from these, the request body also accepts `report_missing` which can only be a list of user ids and behaves the same way as the query parameter.\n\nAll three of these should be considered mutually exclusive. The server however does not error if more than one is specified, it reads them in this order of precedence:\n- `report_missing` in the request body has highest precedence.\n- `ignore_missing` in the query param is the next.\n- `report_missing` in the query param has the lowest precedence.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-otr-message-unqualified","parameters":[{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"ignore_missing","required":false,"schema":{"type":"string"}},{"in":"query","name":"report_missing","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}},"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Message sent"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` or Conversation not found (label: `no-conversation`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Missing clients"}}}},"/broadcast/otr/messages":{"post":{"summary":"Broadcast an encrypted message to all team members and all contacts (accepts JSON or Protobuf)","description":" [internal route ID: \"post-otr-broadcast-unqualified\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts two query params:\n- `ignore_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are ignored.\n - When 'false' all missing clients are reported.\n - When comma separated list of user-ids, only clients for listed users are ignored.\n- `report_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are reported.\n - When 'false' all missing clients are ignored.\n - When comma separated list of user-ids, only clients for listed users are reported.\n\nApart from these, the request body also accepts `report_missing` which can only be a list of user ids and behaves the same way as the query parameter.\n\nAll three of these should be considered mutually exclusive. The server however does not error if more than one is specified, it reads them in this order of precedence:\n- `report_missing` in the request body has highest precedence.\n- `ignore_missing` in the query param is the next.\n- `report_missing` in the query param has the lowest precedence.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-otr-broadcast-unqualified","parameters":[{"in":"query","name":"ignore_missing","required":false,"schema":{"type":"string"}},{"in":"query","name":"report_missing","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}},"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Message sent"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"too-many-users-to-broadcast","message":"Too many users to fan out the broadcast event to"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["too-many-users-to-broadcast"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `report_missing` or `ignore_missing`\n\nToo many users to fan out the broadcast event to (label: `too-many-users-to-broadcast`)"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","non-binding-team","no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nNot a member of a binding team (label: `non-binding-team`)\n\nTeam not found (label: `no-team`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Missing clients"}}}},"/conversations/{cnv_domain}/{cnv}/proteus/messages":{"post":{"summary":"Post an encrypted message to a conversation (accepts only Protobuf)","description":" [internal route ID: \"post-proteus-message\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts `client_mismatch_strategy` in the body. It can have these values:\n- `report_all`: When set, the message is not sent if any clients are missing. The missing clients are reported in the response.\n- `ignore_all`: When set, no checks about missing clients are carried out.\n- `report_only`: Takes a list of qualified UserIDs. If any clients of the listed users are missing, the message is not sent. The missing clients are reported in the response.\n- `ignore_only`: Takes a list of qualified UserIDs. If any clients of the non-listed users are missing, the message is not sent. The missing clients are reported in the response.\n\nThe sending of messages in a federated conversation could theoretically fail partially. To make this case unlikely, the backend first gets a list of clients from all the involved backends and then tries to send a message. So, if any backend is down, the message is not propagated to anyone. But the actual message fan out to multiple backends could still fail partially. This type of failure is reported as a 201, the clients for which the message sending failed are part of the response body.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-proteus-message","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/QualifiedNewOtrMessage"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Message sent"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or Conversation not found (label: `no-conversation`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Missing clients"}}}},"/broadcast/proteus/messages":{"post":{"summary":"Post an encrypted message to all team members and all contacts (accepts only Protobuf)","description":" [internal route ID: \"post-proteus-broadcast\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts `client_mismatch_strategy` in the body. It can have these values:\n- `report_all`: When set, the message is not sent if any clients are missing. The missing clients are reported in the response.\n- `ignore_all`: When set, no checks about missing clients are carried out.\n- `report_only`: Takes a list of qualified UserIDs. If any clients of the listed users are missing, the message is not sent. The missing clients are reported in the response.\n- `ignore_only`: Takes a list of qualified UserIDs. If any clients of the non-listed users are missing, the message is not sent. The missing clients are reported in the response.\n\nThe sending of messages in a federated conversation could theoretically fail partially. To make this case unlikely, the backend first gets a list of clients from all the involved backends and then tries to send a message. So, if any backend is down, the message is not propagated to anyone. But the actual message fan out to multiple backends could still fail partially. This type of failure is reported as a 201, the clients for which the message sending failed are part of the response body.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-proteus-broadcast","requestBody":{"content":{"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/QualifiedNewOtrMessage"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Message sent"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"too-many-users-to-broadcast","message":"Too many users to fan out the broadcast event to"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["too-many-users-to-broadcast"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nToo many users to fan out the broadcast event to (label: `too-many-users-to-broadcast`)"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","non-binding-team","no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nNot a member of a binding team (label: `non-binding-team`)\n\nTeam not found (label: `no-team`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Missing clients"}}}},"/bot/messages":{"post":{"description":" [internal route ID: \"post-bot-message-unqualified\"]\n\n","operationId":"post-bot-message-unqualified","parameters":[{"in":"query","name":"ignore_missing","required":false,"schema":{"type":"string"}},{"in":"query","name":"report_missing","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Message sent"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation not found (label: `no-conversation`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Missing clients"}}}},"/bot/conversation":{"get":{"description":" [internal route ID: \"get-bot-conversation\"]\n\n","operationId":"get-bot-conversation","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/BotConvView_LTYzMjIzMjQz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)"}}}},"/teams/{tid}":{"get":{"summary":"Get a team by ID","description":" [internal route ID: \"get-team\"]\n\n","operationId":"get-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Team_NDg4MjQwOTIw"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Update team properties","description":" [internal route ID: \"update-team\"]\n\n","operationId":"update-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamUpdateData_LTE0NTM2NTU5"}}},"required":true},"responses":{"200":{"description":"Team updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions (missing SetTeamData)"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (missing SetTeamData) (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}}},"delete":{"summary":"Delete a team","description":" [internal route ID: \"delete-team\"]\n\n","operationId":"delete-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamDeleteData_ODI5NTU0ODE5"}}},"required":true},"responses":{"202":{"description":"Team is scheduled for removal"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Verification code required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed","access-denied","operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Verification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (missing DeleteTeam) (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"503":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":503,"label":"queue-full","message":"The delete queue is full; no further delete requests can be processed at the moment"},"properties":{"code":{"enum":[503],"type":"integer"},"label":{"enum":["queue-full"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The delete queue is full; no further delete requests can be processed at the moment (label: `queue-full`)"}}}},"/teams/{tid}/channels/search":{"get":{"summary":"Search channels","description":" [internal route ID: \"search-channels\"]\n\n","operationId":"search-channels","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Search string","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"in":"query","name":"sort_order","required":false,"schema":{"enum":["asc","desc"],"type":"string"}},{"in":"query","name":"page_size","required":false,"schema":{"description":"integer from [1..500]","type":"number"}},{"description":"`name` of the last seen channel of the current page, used to get the next page.","in":"query","name":"last_seen_name","required":false,"schema":{"type":"string"}},{"description":"`id` of the last seen channel, used to get the next page, used as a tie breaker. **Must** be sent to get the next page.","in":"query","name":"last_seen_id","required":false,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"discoverable","schema":{"default":false,"type":"boolean"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationPage_LTIwMDU2NDI3"}}},"description":""}}}},"/teams/{tid}/features/sso":{"get":{"summary":"Get config for sso","description":" [internal route ID: (\"get\", SSOConfig)]\n\n","operationId":"get_SSOConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SSOConfig_NjcyMjU4MDY2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/legalhold":{"get":{"summary":"Get config for legalhold","description":" [internal route ID: (\"get\", LegalholdConfig)]\n\n","operationId":"get_LegalholdConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_LegalholdConfig_LTc5MTk5OTIw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for legalhold","description":" [internal route ID: (\"put\", LegalholdConfig)]\n\n","operationId":"put_LegalholdConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_LegalholdConfig_NjM3MTkxNjYw"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_LegalholdConfig_LTc5MTk5OTIw"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-disable-unimplemented","message":"legal hold cannot be disabled for whitelisted teams"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-disable-unimplemented","legalhold-not-enabled","too-large-team-for-legalhold","action-denied","no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nCannot enable legalhold on large teams (reason: for removing LH from team, we need to iterate over all members, which is only supported for teams with less than 2k members) (label: `too-large-team-for-legalhold`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}}}},"/teams/{tid}/features/searchVisibility":{"get":{"summary":"Get config for searchVisibility","description":" [internal route ID: (\"get\", SearchVisibilityAvailableConfig)]\n\n","operationId":"get_SearchVisibilityAvailableConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for searchVisibility","description":" [internal route ID: (\"put\", SearchVisibilityAvailableConfig)]\n\n","operationId":"put_SearchVisibilityAvailableConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_SearchVisibilityAvailableConfig_LTMzNTkxODI1"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/search-visibility":{"get":{"summary":"Shows the value for search visibility","description":" [internal route ID: \"get-search-visibility\"]\n\n","operationId":"get-search-visibility","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSearchVisibilityView_Mzg3MzMzMTk3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}}},"put":{"summary":"Sets the search visibility for the whole team","description":" [internal route ID: \"set-search-visibility\"]\n\n","operationId":"set-search-visibility","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSearchVisibilityView_Mzg3MzMzMTk3"}}},"required":true},"responses":{"204":{"description":"Search visibility set"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"team-search-visibility-not-enabled","message":"Custom search is not available for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["team-search-visibility-not-enabled","operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Custom search is not available for this team (label: `team-search-visibility-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/validateSAMLemails":{"get":{"summary":"Get config for validateSAMLemails","description":" [internal route ID: (\"get\", RequireExternalEmailVerificationConfig)]\n\n

Controls whether externally managed email addresses (from SAML or SCIM) must be verified by the user, or are auto-activated.

The external feature name is kept as validateSAMLemails for backward compatibility. That name is misleading because the feature also applies to SCIM-managed users, and it controls email ownership verification rather than generic email validation.

","operationId":"get_RequireExternalEmailVerificationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_RqiExnmVfCg_NjUyMzgzNzY5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/digitalSignatures":{"get":{"summary":"Get config for digitalSignatures","description":" [internal route ID: (\"get\", DigitalSignaturesConfig)]\n\n","operationId":"get_DigitalSignaturesConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_DigitalSignaturesConfig_ODM2MDA2NDU4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/appLock":{"get":{"summary":"Get config for appLock","description":" [internal route ID: (\"get\", AppLockConfigB)]\n\n","operationId":"get_AppLockConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for appLock","description":" [internal route ID: (\"put\", AppLockConfigB)]\n\n","operationId":"put_AppLockConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_AppLockConfigB_Bare_Identity_MTAzMjI5NDYy"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/fileSharing":{"get":{"summary":"Get config for fileSharing","description":" [internal route ID: (\"get\", FileSharingConfig)]\n\n","operationId":"get_FileSharingConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_FileSharingConfig_MjgwNjIzODEz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for fileSharing","description":" [internal route ID: (\"put\", FileSharingConfig)]\n\n","operationId":"put_FileSharingConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_FileSharingConfig_LTUyNjkxMzM4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_FileSharingConfig_MjgwNjIzODEz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/classifiedDomains":{"get":{"summary":"Get config for classifiedDomains","description":" [internal route ID: (\"get\", ClassifiedDomainsConfig)]\n\n","operationId":"get_ClassifiedDomainsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ClassifiedDomainsConfig_LTY1ODQwODg1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/conferenceCalling":{"get":{"summary":"Get config for conferenceCalling","description":" [internal route ID: (\"get\", ConferenceCallingConfigB)]\n\n","operationId":"get_ConferenceCallingConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_CnfigBIdy_NzY1NDU5MDAy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for conferenceCalling","description":" [internal route ID: (\"put\", ConferenceCallingConfigB)]\n\n","operationId":"put_ConferenceCallingConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_ConferenceCallingConfigB_Bare_Identity_Njc2NTcxNTI3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_CnfigBIdy_NzY1NDU5MDAy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/selfDeletingMessages":{"get":{"summary":"Get config for selfDeletingMessages","description":" [internal route ID: (\"get\", SelfDeletingMessagesConfigB)]\n\n","operationId":"get_SelfDeletingMessagesConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for selfDeletingMessages","description":" [internal route ID: (\"put\", SelfDeletingMessagesConfigB)]\n\n","operationId":"put_SelfDeletingMessagesConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Featur_SlfDingMsCoBIdy_LTg2MzYzNjc2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/conversationGuestLinks":{"get":{"summary":"Get config for conversationGuestLinks","description":" [internal route ID: (\"get\", GuestLinksConfig)]\n\n","operationId":"get_GuestLinksConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for conversationGuestLinks","description":" [internal route ID: (\"put\", GuestLinksConfig)]\n\n","operationId":"put_GuestLinksConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_GuestLinksConfig_NjQyMDMxNjg3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/sndFactorPasswordChallenge":{"get":{"summary":"Get config for sndFactorPasswordChallenge","description":" [internal route ID: (\"get\", SndFactorPasswordChallengeConfig)]\n\n","operationId":"get_SndFactorPasswordChallengeConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for sndFactorPasswordChallenge","description":" [internal route ID: (\"put\", SndFactorPasswordChallengeConfig)]\n\n","operationId":"put_SndFactorPasswordChallengeConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_SndFactorPasswordChallengeConfig_NDc0MzUyMzQz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/mls":{"get":{"summary":"Get config for mls","description":" [internal route ID: (\"get\", MLSConfigB)]\n\n","operationId":"get_MLSConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for mls","description":" [internal route ID: (\"put\", MLSConfigB)]\n\n","operationId":"put_MLSConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MLSConfigB_Bare_Identity_LTI5MjA3MDYy"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/exposeInvitationURLsToTeamAdmin":{"get":{"summary":"Get config for exposeInvitationURLsToTeamAdmin","description":" [internal route ID: (\"get\", ExposeInvitationURLsToTeamAdminConfig)]\n\n","operationId":"get_ExposeInvitationURLsToTeamAdminConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for exposeInvitationURLsToTeamAdmin","description":" [internal route ID: (\"put\", ExposeInvitationURLsToTeamAdminConfig)]\n\n","operationId":"put_ExposeInvitationURLsToTeamAdminConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_ExposeInvitationURLsToTeamAdminConfig_LTY5NzY2Mzg5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/searchVisibilityInbound":{"get":{"summary":"Get config for searchVisibilityInbound","description":" [internal route ID: (\"get\", SearchVisibilityInboundConfig)]\n\n","operationId":"get_SearchVisibilityInboundConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for searchVisibilityInbound","description":" [internal route ID: (\"put\", SearchVisibilityInboundConfig)]\n\n","operationId":"put_SearchVisibilityInboundConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_SearchVisibilityInboundConfig_MTI1NzQxODY2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/outlookCalIntegration":{"get":{"summary":"Get config for outlookCalIntegration","description":" [internal route ID: (\"get\", OutlookCalIntegrationConfig)]\n\n","operationId":"get_OutlookCalIntegrationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for outlookCalIntegration","description":" [internal route ID: (\"put\", OutlookCalIntegrationConfig)]\n\n","operationId":"put_OutlookCalIntegrationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_OutlookCalIntegrationConfig_LTg0MjIxNTMx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/mlsE2EId":{"get":{"summary":"Get config for mlsE2EId","description":" [internal route ID: (\"get\", MlsE2EIdConfigB)]\n\n","operationId":"get_MlsE2EIdConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for mlsE2EId","description":" [internal route ID: (\"put\", MlsE2EIdConfigB)]\n\n","operationId":"put_MlsE2EIdConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MlsE2EIdConfigB_Bare_Identity_LTUxODYzODEx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/mlsMigration":{"get":{"summary":"Get config for mlsMigration","description":" [internal route ID: (\"get\", MlsMigrationConfigB)]\n\n","operationId":"get_MlsMigrationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_MsignCfBIdy_LTE1NjAxNjU2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for mlsMigration","description":" [internal route ID: (\"put\", MlsMigrationConfigB)]\n\n","operationId":"put_MlsMigrationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MlsMigrationConfigB_Bare_Identity_LTQyMDAxMTkz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_MsignCfBIdy_LTE1NjAxNjU2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/enforceFileDownloadLocation":{"get":{"summary":"Get config for enforceFileDownloadLocation","description":" [internal route ID: (\"get\", EnforceFileDownloadLocationConfigB)]\n\n

Custom feature: only supported on some dedicated on-prem systems.

","operationId":"get_EnforceFileDownloadLocationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for enforceFileDownloadLocation","description":" [internal route ID: (\"put\", EnforceFileDownloadLocationConfigB)]\n\n

Custom feature: only supported on some dedicated on-prem systems.

","operationId":"put_EnforceFileDownloadLocationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Featur_EnfocilDwdLCgBIy_MjcxMzc1NDA5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/limitedEventFanout":{"get":{"summary":"Get config for limitedEventFanout","description":" [internal route ID: (\"get\", LimitedEventFanoutConfig)]\n\n","operationId":"get_LimitedEventFanoutConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_LimitedEventFanoutConfig_MTg3ODM0NzU0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/feature-configs":{"get":{"summary":"Gets feature configs for a user","description":" [internal route ID: \"get-all-feature-configs-for-user\"]\n\nGets feature configs for a user. If the user is a member of a team and has the required permissions, this will return the team's feature configs.If the user is not a member of a team, this will return the personal feature configs (the server defaults).\nOAuth scope: `read:feature_configs`","operationId":"get-all-feature-configs-for-user","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NP_LockableFtur_ghdCnfiSOVsyAvIRqExmDpB_MGwUT2Q_LTM1Mzc0ODgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)"}}}},"/teams/{tid}/features":{"get":{"summary":"Gets feature configs for a team","description":" [internal route ID: \"get-all-feature-configs-for-team\"]\n\nGets feature configs for a team. User must be a member of the team and have permission to view team features.","operationId":"get-all-feature-configs-for-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NP_LockableFtur_ghdCnfiSOVsyAvIRqExmDpB_MGwUT2Q_LTM1Mzc0ODgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/domainRegistration":{"get":{"summary":"Get config for domainRegistration","description":" [internal route ID: (\"get\", DomainRegistrationConfig)]\n\n","operationId":"get_DomainRegistrationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_DomainRegistrationConfig_NzU0NjczNTE0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/channels":{"get":{"summary":"Get config for channels","description":" [internal route ID: (\"get\", ChannelsConfigB)]\n\n","operationId":"get_ChannelsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for channels","description":" [internal route ID: (\"put\", ChannelsConfigB)]\n\n","operationId":"put_ChannelsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_ChannelsConfigB_Bare_Identity_LTU4NTE4MTgx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/preventAdminlessGroups":{"get":{"summary":"Get config for preventAdminlessGroups","description":" [internal route ID: (\"get\", PreventAdminlessGroupsConfigB)]\n\n","operationId":"get_PreventAdminlessGroupsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for preventAdminlessGroups","description":" [internal route ID: \"put-PreventAdminlessGroupsConfig@v17\"]\n\n

For API version 17, use duration strings for the timeout fields. The request body must have the following shape:

{\n  "config": {\n    "deletionTimeoutDuration": "7d",\n    "promotionStrategy": "alphabetical",\n    "reminderTimeoutDurations": [\n      "2d",\n      "4d",\n      "6d"\n    ]\n  },\n  "status": "enabled"\n}

Older API versions use the legacy numeric fields deletionTimeout and reminderTimeouts.

","operationId":"put-PreventAdminlessGroupsConfig@v17","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Featur_Vsiond_17PvAmlGpCfgBIy_LTIzMjQ4MDk1V17"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/cells":{"get":{"summary":"Get config for cells","description":" [internal route ID: (\"get\", CellsConfigB)]\n\n","operationId":"get_CellsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for cells","description":" [internal route ID: (\"put\", CellsConfigB)]\n\n","operationId":"put_CellsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_CellsConfigB_Bare_Identity_NzU2NjgxNzEw"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/allowedGlobalOperations":{"get":{"summary":"Get config for allowedGlobalOperations","description":" [internal route ID: (\"get\", AllowedGlobalOperationsConfig)]\n\n","operationId":"get_AllowedGlobalOperationsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AllowedGlobalOperationsConfig_NjQ1MjA5MDYw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/assetAuditLog":{"get":{"summary":"Get config for assetAuditLog","description":" [internal route ID: (\"get\", AssetAuditLogConfig)]\n\n","operationId":"get_AssetAuditLogConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AssetAuditLogConfig_NDQ3MzcyMzk2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/consumableNotifications":{"get":{"summary":"Get config for consumableNotifications","description":" [internal route ID: (\"get\", ConsumableNotificationsConfig)]\n\n","operationId":"get_ConsumableNotificationsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ConsumableNotificationsConfig_MjUxMjczMjM0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/chatBubbles":{"get":{"summary":"Get config for chatBubbles","description":" [internal route ID: (\"get\", ChatBubblesConfig)]\n\n","operationId":"get_ChatBubblesConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ChatBubblesConfig_NDgwMTQzNjI2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/apps":{"get":{"summary":"Get config for apps","description":" [internal route ID: (\"get\", AppsConfig)]\n\n","operationId":"get_AppsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AppsConfig_MzQyNTMxNTk5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/simplifiedUserConnectionRequestQRCode":{"get":{"summary":"Get config for simplifiedUserConnectionRequestQRCode","description":" [internal route ID: (\"get\", SimplifiedUserConnectionRequestQRCodeConfig)]\n\n","operationId":"get_SimplifiedUserConnectionRequestQRCodeConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_SimpfdUsCnRqQg_Njk5NjU4OTgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/stealthUsers":{"get":{"summary":"Get config for stealthUsers","description":" [internal route ID: (\"get\", StealthUsersConfig)]\n\n","operationId":"get_StealthUsersConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_StealthUsersConfig_LTE1MTk2NzIz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/cellsInternal":{"get":{"summary":"Get config for cellsInternal","description":" [internal route ID: (\"get\", CellsInternalConfigB)]\n\n","operationId":"get_CellsInternalConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_CsInfigBdy_LTQ3MTU3Mjg0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/meetings":{"get":{"summary":"Get config for meetings","description":" [internal route ID: (\"get\", MeetingsConfig)]\n\n","operationId":"get_MeetingsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for meetings","description":" [internal route ID: (\"put\", MeetingsConfig)]\n\n","operationId":"put_MeetingsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MeetingsConfig_NDc2MzM0MDE1"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/mls/messages":{"post":{"summary":"Post an MLS message","description":" [internal route ID: \"mls-message\"]\n\n\n\n**Note**: this endpoint can execute proposals, and therefore return all possible errors associated with adding or removing members to a conversation, in addition to the ones listed below. See the documentation of [POST /conversations/{cnv}/members/v2](#/default/post_conversations__cnv__members_v2) and [POST /conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}](#/default/delete_conversations__cnv_domain___cnv__members__usr_domain___usr_) for more details on the possible error responses of each type of proposal.","operationId":"mls-message","requestBody":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/MLSMessage"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}}},"description":"Message sent"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-invalid-leaf-node-signature","message":"Invalid leaf node signature"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-invalid-leaf-node-signature","mls-self-removal-not-allowed","mls-protocol-error","mls-not-enabled","mls-invalid-leaf-node-index","mls-group-conversation-mismatch","mls-commit-missing-references","mls-client-sender-user-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid leaf node signature (label: `mls-invalid-leaf-node-signature`)\n\nSubmitted group info is inconsistent with the backend group state (label `inconsistent-group-state`)\n\nSelf removal from group is not allowed (label: `mls-self-removal-not-allowed`)\n\nMLS protocol error (label: `mls-protocol-error`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nA referenced leaf node index points to a blank or non-existing node (label: `mls-invalid-leaf-node-index`)\n\nConversation ID resolved from Group ID does not match submitted Conversation ID (label: `mls-group-conversation-mismatch`)\n\nThe commit is not referencing all pending proposals (label: `mls-commit-missing-references`)\n\nUser ID resolved from Client ID does not match message's sender user ID (label: `mls-client-sender-user-mismatch`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-subconv-join-parent-missing","message":"MLS client cannot join the subconversation because it is not member of the parent conversation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-subconv-join-parent-missing","missing-legalhold-consent","legalhold-not-enabled","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS client cannot join the subconversation because it is not member of the parent conversation (label: `mls-subconv-join-parent-missing`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-proposal-not-found","message":"A proposal referenced in a commit message could not be found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-proposal-not-found","no-conversation","no-conversation-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"A proposal referenced in a commit message could not be found (label: `mls-proposal-not-found`)\n\nConversation not found (label: `no-conversation`)\n\nConversation member not found (label: `no-conversation-member`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"missing_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["missing_users"],"type":"object"}}},"description":"Group is out of sync\n\nAdding members to the conversation is not possible because the backends involved do not form a fully connected graph\n\nThe conversation epoch in a message is too old (label: `mls-stale-message`)\n\nA proposal of type Add or Remove does not apply to the full list of clients for a user (label: `mls-client-mismatch`)"},"422":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":422,"label":"mls-unsupported-proposal","message":"Unsupported proposal type"},"properties":{"code":{"enum":[422],"type":"integer"},"label":{"enum":["mls-unsupported-proposal","mls-unsupported-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unsupported proposal type (label: `mls-unsupported-proposal`)\n\nAttempted to send a message with an unsupported combination of content type and wire format (label: `mls-unsupported-message`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}}},"/mls/commit-bundles":{"post":{"summary":"Post a MLS CommitBundle","description":" [internal route ID: \"mls-commit-bundle\"]\n\n\n\n**Note**: this endpoint can execute proposals, and therefore return all possible errors associated with adding or removing members to a conversation, in addition to the ones listed below. See the documentation of [POST /conversations/{cnv}/members/v2](#/default/post_conversations__cnv__members_v2) and [POST /conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}](#/default/delete_conversations__cnv_domain___cnv__members__usr_domain___usr_) for more details on the possible error responses of each type of proposal.","operationId":"mls-commit-bundle","requestBody":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/CommitBundle"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}}},"description":"Commit accepted and forwarded"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-invalid-leaf-node-signature","message":"Invalid leaf node signature"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-invalid-leaf-node-signature","mls-group-id-not-supported","mls-welcome-mismatch","mls-self-removal-not-allowed","mls-protocol-error","mls-not-enabled","mls-invalid-leaf-node-index","mls-group-conversation-mismatch","mls-commit-missing-references","mls-client-sender-user-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid leaf node signature (label: `mls-invalid-leaf-node-signature`)\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)\n\nSubmitted group info is inconsistent with the backend group state (label `inconsistent-group-state`)\n\nThe list of targets of a welcome message does not match the list of new clients in a group (label: `mls-welcome-mismatch`)\n\nSelf removal from group is not allowed (label: `mls-self-removal-not-allowed`)\n\nMLS protocol error (label: `mls-protocol-error`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nA referenced leaf node index points to a blank or non-existing node (label: `mls-invalid-leaf-node-index`)\n\nConversation ID resolved from Group ID does not match submitted Conversation ID (label: `mls-group-conversation-mismatch`)\n\nThe commit is not referencing all pending proposals (label: `mls-commit-missing-references`)\n\nUser ID resolved from Client ID does not match message's sender user ID (label: `mls-client-sender-user-mismatch`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-identity-mismatch","message":"Leaf node signature key does not match the client's key"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-identity-mismatch","mls-subconv-join-parent-missing","missing-legalhold-consent","legalhold-not-enabled","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Leaf node signature key does not match the client's key (label: `mls-identity-mismatch`)\n\nMLS client cannot join the subconversation because it is not member of the parent conversation (label: `mls-subconv-join-parent-missing`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-proposal-not-found","message":"A proposal referenced in a commit message could not be found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-proposal-not-found","no-conversation","no-conversation-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"A proposal referenced in a commit message could not be found (label: `mls-proposal-not-found`)\n\nConversation not found (label: `no-conversation`)\n\nConversation member not found (label: `no-conversation-member`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"missing_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["missing_users"],"type":"object"}}},"description":"Group is out of sync\n\nAdding members to the conversation is not possible because the backends involved do not form a fully connected graph\n\nA user who is under legal-hold may not participate in MLS conversations (label: `mls-legal-hold-not-allowed`)\n\nThe conversation epoch in a message is too old (label: `mls-stale-message`)\n\nA proposal of type Add or Remove does not apply to the full list of clients for a user (label: `mls-client-mismatch`)"},"422":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":422,"label":"mls-unsupported-proposal","message":"Unsupported proposal type"},"properties":{"code":{"enum":[422],"type":"integer"},"label":{"enum":["mls-unsupported-proposal","mls-unsupported-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unsupported proposal type (label: `mls-unsupported-proposal`)\n\nAttempted to send a message with an unsupported combination of content type and wire format (label: `mls-unsupported-message`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}}},"/mls/public-keys":{"get":{"summary":"Get public keys used by the backend to sign external proposals","description":" [internal route ID: \"mls-public-keys\"]\n\nThe format of the returned key is determined by the `format` query parameter:\n - raw (default): base64-encoded raw public keys\n - jwk: keys are nested objects in JWK format.","operationId":"mls-public-keys","parameters":[{"in":"query","name":"format","required":false,"schema":{"enum":["raw","jwk"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx"}}},"description":"Public keys"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `format`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"}}}},"/mls/reset-conversation":{"post":{"summary":"Reset an MLS conversation to epoch 0","description":" [internal route ID: \"mls-reset-conversation\"]\n\n","operationId":"mls-reset-conversation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSReset_NzgwODA3ODc4"}}},"required":true},"responses":{"200":{"description":"Conversation reset"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-protocol-error","message":"MLS protocol error"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-protocol-error","mls-group-id-not-supported","mls-federated-reset-not-supported","mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS protocol error (label: `mls-protocol-error`)\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)\n\nReset is not supported by the owning backend of the conversation (label: `mls-federated-reset-not-supported`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`) or `body`"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"action-denied","message":"Insufficient authorization (missing leave_conversation)"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["action-denied","invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-stale-message","message":"The conversation epoch in a message is too old"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-stale-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The conversation epoch in a message is too old (label: `mls-stale-message`)"}}}},"/meetings":{"post":{"summary":"Create a new meeting","description":" [internal route ID: \"create-meeting\"]\n\n","operationId":"create-meeting","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewMeeting_LTI1NTMzOTU5"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeetingWithConversation_LTMyNzA4NzU0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MeetingWithConversation_LTMyNzA4NzU0"}}},"description":"Meeting created"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid meeting times, empty update, or meetings feature disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid meeting times, empty update, or meetings feature disabled (label: `invalid-op`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}}},"/meetings/{domain}/{id}":{"get":{"summary":"Get a single meeting by ID","description":" [internal route ID: \"get-meeting\"]\n\n","operationId":"get-meeting","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}}},"put":{"summary":"Update an existing meeting","description":" [internal route ID: \"update-meeting\"]\n\n","operationId":"update-meeting","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateMeeting_NTExNzYxMTcz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeetingWithConversation_LTMyNzA4NzU0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MeetingWithConversation_LTMyNzA4NzU0"}}},"description":"Meeting updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid meeting times, empty update, or meetings feature disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid meeting times, empty update, or meetings feature disabled (label: `invalid-op`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}}},"delete":{"summary":"Delete a meeting","description":" [internal route ID: \"delete-meeting\"]\n\n","operationId":"delete-meeting","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"Meeting deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid meeting times, empty update, or meetings feature disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid meeting times, empty update, or meetings feature disabled (label: `invalid-op`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}}}},"/meetings/list":{"get":{"summary":"List all meetings for the authenticated user","description":" [internal route ID: \"list-meetings\"]\n\n","operationId":"list-meetings","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"},"type":"array"}}},"description":""}}}},"/meetings/{domain}/{id}/invitations":{"put":{"summary":"Replace the invited emails","description":" [internal route ID: \"replace-meeting-invitation\"]\n\n","operationId":"replace-meeting-invitation","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MeetingEmailsInvitation_NzgyNzUzMzcz"}}},"required":true},"responses":{"200":{"description":"Invitations replaced"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid meeting times, empty update, or meetings feature disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid meeting times, empty update, or meetings feature disabled (label: `invalid-op`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}}},"post":{"summary":"Add an email to the invited emails","description":" [internal route ID: \"add-meeting-invitation\"]\n\n","operationId":"add-meeting-invitation","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MeetingEmailsInvitation_NzgyNzUzMzcz"}}},"required":true},"responses":{"200":{"description":"Invitation added"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid meeting times, empty update, or meetings feature disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid meeting times, empty update, or meetings feature disabled (label: `invalid-op`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}}}},"/meetings/{domain}/{id}/invitations/delete":{"post":{"summary":"Remove emails from the invited emails","description":" [internal route ID: \"remove-meeting-invitation\"]\n\n","operationId":"remove-meeting-invitation","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MeetingEmailsInvitation_NzgyNzUzMzcz"}}},"required":true},"responses":{"200":{"description":"Invitations removed"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid meeting times, empty update, or meetings feature disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid meeting times, empty update, or meetings feature disabled (label: `invalid-op`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}}}},"/custom-backend/by-domain/{domain}":{"get":{"summary":"Shows information about custom backends related to a given email domain","description":" [internal route ID: \"get-custom-backend-by-domain\"]\n\n","operationId":"get-custom-backend-by-domain","parameters":[{"description":"URL-encoded email domain","in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CustomBackend_LTQxODI0MjQ0"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"custom-backend-not-found","message":"Custom backend not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["custom-backend-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` not found\n\nCustom backend not found (label: `custom-backend-not-found`)"}}}},"/teams/{tid}/legalhold/settings":{"get":{"summary":"Get legal hold service settings","description":" [internal route ID: \"get-legal-hold-settings\"]\n\n","operationId":"get-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}}},"post":{"summary":"Create legal hold service settings","description":" [internal route ID: \"create-legal-hold-settings\"]\n\n","operationId":"create-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewLegalHoldService_Mzg0ODQ5NDU1"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}}},"description":"Legal hold service settings created"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-status-bad","message":"legal hold service: invalid response"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-status-bad","legalhold-invalid-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)\n\nlegal hold service pubkey is invalid (label: `legalhold-invalid-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}}},"delete":{"summary":"Delete legal hold service settings","description":" [internal route ID: \"delete-legal-hold-settings\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to members with a legalhold client (via brig)\n- UserLegalHoldDisabled event to contacts of members with a legalhold client (via brig)","operationId":"delete-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RemoveLegalHoldSettingsRequest_NzMzMzMyMDAz"}}},"required":true},"responses":{"204":{"description":"Legal hold service settings deleted"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-disable-unimplemented","message":"legal hold cannot be disabled for whitelisted teams"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-disable-unimplemented","legalhold-not-enabled","invalid-op","action-denied","no-team-member","operation-denied","code-authentication-required","code-authentication-failed","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}}}},"/teams/{tid}/legalhold/{uid}":{"get":{"summary":"Get legal hold status","description":" [internal route ID: \"get-legal-hold\"]\n\n","operationId":"get-legal-hold","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserLegalHoldStatusResponse_LTQ1MzUxMTE3"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"}}},"post":{"summary":"Request legal hold device","description":" [internal route ID: \"request-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- LegalHoldClientRequested event to contacts of the user the device is requested for, if they didn't already have a legalhold client (via brig)","operationId":"request-legal-hold-device","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"201":{"description":"Request device successful"},"204":{"description":"Request device already pending"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered","legalhold-status-bad"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service has not been registered for this team (label: `legalhold-not-registered`)\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","operation-denied","no-team-member","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-legal-hold-not-allowed","message":"A user who is under legal-hold may not participate in MLS conversations"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-legal-hold-not-allowed","legalhold-no-consent","legalhold-already-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"A user who is under legal-hold may not participate in MLS conversations (label: `mls-legal-hold-not-allowed`)\n\nuser has not given consent to using legal hold (label: `legalhold-no-consent`)\n\nlegal hold is already enabled for this user (label: `legalhold-already-enabled`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-illegal-op","message":"internal server error: inconsistent change of user's legalhold state"},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-illegal-op","legalhold-internal"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"internal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)\n\nlegal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)"}}},"delete":{"summary":"Disable legal hold for user","description":" [internal route ID: \"disable-legal-hold-for-user\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to the user owning the client (via brig)\n- UserLegalHoldDisabled event to contacts of the user owning the client (via brig)","operationId":"disable-legal-hold-for-user","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DisableLegalHoldForUserRequest_LTYyMDYxOTEy"}}},"required":true},"responses":{"200":{"description":"Disable legal hold successful"},"204":{"description":"Legal hold was not enabled"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","action-denied","code-authentication-required","code-authentication-failed","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}}}},"/teams/{tid}/legalhold/consent":{"post":{"summary":"Consent to legal hold","description":" [internal route ID: \"consent-to-legal-hold\"]\n\n","operationId":"consent-to-legal-hold","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"201":{"description":"Grant consent successful"},"204":{"description":"Consent already granted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam member not found (label: `no-team-member`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}}}},"/teams/{tid}/legalhold/{uid}/approve":{"put":{"summary":"Approve legal hold device","description":" [internal route ID: \"approve-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientAdded event to the user owning the client (via brig)\n- UserLegalHoldEnabled event to contacts of the user owning the client (via brig)\n- ClientRemoved event to the user, if removing old client due to max number (via brig)","operationId":"approve-legal-hold-device","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ApproveLegalHoldForUserRequest_NjEyNzYyMTIx"}}},"required":true},"responses":{"200":{"description":"Legal hold approved"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","no-team-member","action-denied","access-denied","code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"legalhold-no-device-allocated","message":"no legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["legalhold-no-device-allocated"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nno legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow. (label: `legalhold-no-device-allocated`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"legalhold-already-enabled","message":"legal hold is already enabled for this user"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["legalhold-already-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is already enabled for this user (label: `legalhold-already-enabled`)"},"412":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":412,"label":"legalhold-not-pending","message":"legal hold cannot be approved without being in a pending state"},"properties":{"code":{"enum":[412],"type":"integer"},"label":{"enum":["legalhold-not-pending"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold cannot be approved without being in a pending state (label: `legalhold-not-pending`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}}}},"/teams/{tid}/members":{"get":{"summary":"Get team members","description":" [internal route ID: \"get-team-members\"]\n\n","operationId":"get-team-members","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Maximum results to be returned","in":"query","name":"maxResults","required":false,"schema":{"format":"int32","maximum":2000,"minimum":1,"type":"integer"}},{"description":"Optional, when not specified, the first page will be returned.Every returned page contains a `pagingState`, this should be supplied to retrieve the next page.","in":"query","name":"pagingState","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMembersPage_NzYwNDIxODgx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"}}},"put":{"summary":"Update an existing team member","description":" [internal route ID: \"update-team-member\"]\n\n","operationId":"update-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewTeamMember_Required_LTg2NjU5OTI2"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","too-many-team-admins","invalid-permissions","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nMaximum number of admins per team reached (label: `too-many-team-admins`)\n\nThe specified permissions are invalid (label: `invalid-permissions`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member","no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam member not found (label: `no-team-member`)\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/members/{uid}":{"get":{"summary":"Get single team member","description":" [internal route ID: \"get-team-member\"]\n\n","operationId":"get-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMember_Optional_NTU0MDcyNzI1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"}}},"delete":{"summary":"Remove an existing team member","description":" [internal route ID: \"delete-team-member\"]\n\n","operationId":"delete-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMemberDeleteData_LTg2OTEyOTI4"}}},"required":true},"responses":{"200":{"description":""},"202":{"description":"Team member scheduled for deletion"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","access-denied","code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam not found (label: `no-team`)\n\nTeam member not found (label: `no-team-member`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"}}}},"/teams/{tid}/get-members-by-ids-using-post":{"post":{"summary":"Get team members by user id list","description":" [internal route ID: \"get-team-members-by-ids\"]\n\nThe `has_more` field in the response body is always `false`.","operationId":"get-team-members-by-ids","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Maximum results to be returned","in":"query","name":"maxResults","required":false,"schema":{"format":"int32","maximum":2000,"minimum":1,"type":"integer"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserIdList_MzA1MTI1Njgx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMemberList_Optional_LTM1ODE2MzM0"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"too-many-uids","message":"Can only process 2000 user ids per request."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["too-many-uids"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `maxResults`\n\nCan only process 2000 user ids per request. (label: `too-many-uids`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"}}}},"/teams/{tid}/members/csv":{"get":{"summary":"Get all members of the team as a CSV file","description":" [internal route ID: \"get-team-members-csv\"]\n\nThe endpoint returns data in chunked transfer encoding. Internal server errors might result in a failed transfer instead of a 500 response.","operationId":"get-team-members-csv","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/csv":{}},"description":"CSV of team members"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"You do not have permission to access this resource"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"You do not have permission to access this resource (label: `access-denied`)"}}}},"/teams/{tid}/collaborators":{"get":{"summary":"Get all collaborators of the team.","description":" [internal route ID: \"get-team-collaborators\"]\n\n","operationId":"get-team-collaborators","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/TeamCollaborator_LTI3MzM1MTYz"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/TeamCollaborator_LTI3MzM1MTYz"},"type":"array"}}},"description":"Return collaborators"}}},"post":{"summary":"Add a collaborator to the team.","description":" [internal route ID: \"add-team-collaborator\"]\n\n","operationId":"add-team-collaborator","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewTeamCollaborator_LTIxNjEzMTYw"}}},"required":true},"responses":{"200":{"description":""}}}},"/teams/{tid}/collaborators/{uid}":{"put":{"summary":"Update a collaborator permissions from the team.","description":" [internal route ID: \"update-team-collaborator\"]\n\n","operationId":"update-team-collaborator","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/CollaboratorPermission_NDg5NTg2ODgy"},"type":"array","uniqueItems":true}}},"required":true},"responses":{"200":{"description":""}}},"delete":{"summary":"Remove a collaborator from the team.","description":" [internal route ID: \"remove-team-collaborator\"]\n\n","operationId":"remove-team-collaborator","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"}}}},"/teams/notifications":{"get":{"summary":"Read recently added team members from team queue","description":" [internal route ID: \"get-team-notifications\"]\n\n

This is a work-around for scalability issues with gundeck user event fan-out. It does not track all team-wide events, but only `member-join`.

Note that `/teams/notifications` behaves differently from `/notifications`:

  • If there is a gap between the notification id requested with `since` and the available data, team queues respond with 200 and the data that could be found. They do NOT respond with status 404, but valid data in the body.\n
  • The notification with the id given via `since` is included in the response if it exists. You should remove this and only use it to decide whether there was a gap between your last request and this one.\n
  • If the notification id does *not* exist, you get the more recent events from the queue (instead of all of them). This can be done because a notification id is a UUIDv1, which is essentially a time stamp.\n
  • There is no corresponding `/last` end-point to get only the most recent event. That end-point was only useful to avoid having to pull the entire queue. In team queues, if you have never requested the queue before and have no prior notification id, just pull with timestamp 'now'.

See also: GET /notifications

","operationId":"get-team-notifications","parameters":[{"description":"Notification id to start with in the response (UUIDv1)","in":"query","name":"since","required":false,"schema":{"format":"uuid","type":"string"}},{"description":"Maximum number of events to return (1..10000; default: 1000)","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":10000,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotificationList_MTU0ODEyNTQ2"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-notification-id","message":"Could not parse notification id (must be UUIDv1)."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-notification-id"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `size` or `since`\n\nCould not parse notification id (must be UUIDv1). (label: `invalid-notification-id`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)"}}}},"/sso/metadata":{"get":{"description":" [internal route ID: \"sso-metadata\"]\n\nDEPRECATED! use /sso/metadata/:tid instead! Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams","operationId":"sso-metadata","responses":{"200":{"content":{"application/xml":{"schema":{"type":"string"}}},"description":""}},"deprecated":true}},"/sso/metadata/{team}":{"get":{"description":" [internal route ID: \"sso-team-metadata\"]\n\n","operationId":"sso-team-metadata","parameters":[{"in":"path","name":"team","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/xml":{"schema":{"type":"string"}}},"description":""}}}},"/sso/initiate-login/{idp}":{"get":{"description":" [internal route ID: \"auth-req\"]\n\n","operationId":"auth-req","parameters":[{"in":"query","name":"success_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"error_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"label","required":false,"schema":{"type":"string"}},{"in":"path","name":"idp","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/html":{"schema":{"$ref":"#/components/schemas/FormRedirect"}}},"description":""}}},"head":{"description":" [internal route ID: \"auth-req-precheck\"]\n\n","operationId":"auth-req-precheck","parameters":[{"in":"query","name":"success_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"error_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"label","required":false,"schema":{"type":"string"}},{"in":"path","name":"idp","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/plain;charset=utf-8":{}},"description":""}}}},"/sso/finalize-login":{"post":{"description":" [internal route ID: \"auth-resp-legacy\"]\n\nDEPRECATED! use /sso/metadata/:tid instead! Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams","operationId":"auth-resp-legacy","responses":{"200":{"content":{"text/plain;charset=utf-8":{"schema":{"type":"string"}}},"description":""}},"deprecated":true}},"/sso/finalize-login/{team}":{"post":{"description":" [internal route ID: \"auth-resp\"]\n\n","operationId":"auth-resp","parameters":[{"in":"path","name":"team","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/plain;charset=utf-8":{"schema":{"type":"string"}}},"description":""}}}},"/sso/settings":{"get":{"description":" [internal route ID: \"sso-settings\"]\n\n","operationId":"sso-settings","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SsoSettings"}}},"description":""}}}},"/sso/get-by-email":{"post":{"description":" [internal route ID: \"sso-get-by-email\"]\n\n","operationId":"sso-get-by-email","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetByEmailReq_LTY4MzE3Njgy"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}}},"description":"SSO code found"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}}},"description":"SSO code not found or feature disabled"}}}},"/identity-providers/{id}":{"get":{"description":" [internal route ID: \"idp-get\"]\n\n","operationId":"idp-get","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"}}},"description":""}}},"put":{"description":" [internal route ID: \"idp-update\"]\n\n","operationId":"idp-update","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"handle","required":false,"schema":{"maxLength":32,"minLength":1,"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}},"application/xml":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"}}},"description":""}}},"delete":{"description":" [internal route ID: \"idp-delete\"]\n\n","operationId":"idp-delete","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"purge","required":false,"schema":{"type":"boolean"}}],"responses":{"204":{"description":""}}}},"/identity-providers/{id}/raw":{"get":{"description":" [internal route ID: \"idp-get-raw\"]\n\n","operationId":"idp-get-raw","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/xml":{"schema":{"type":"string"}}},"description":""}}}},"/identity-providers":{"get":{"description":" [internal route ID: \"idp-get-all\"]\n\n","operationId":"idp-get-all","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPList"}}},"description":""}}},"post":{"description":" [internal route ID: \"idp-create\"]\n\nCreate a new identity provider.\n\nThe `api_version` parameter controls the uniqueness constraint for IdP issuers:\n- `v1`: IdP issuers must be globally unique across the entire backend (all teams)\n- `v2` (default): IdP issuers must be unique per team (can be reused across different teams)\n\nThese constraints apply to both, multi-ingress and standard backends.","operationId":"idp-create","parameters":[{"in":"query","name":"replaces","required":false,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"api_version","required":false,"schema":{"default":"v2","enum":["v1","v2"],"type":"string"}},{"in":"query","name":"handle","required":false,"schema":{"maxLength":32,"minLength":1,"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}},"application/xml":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}}},"required":true},"responses":{"201":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"}}},"description":""}}}},"/scim/auth-tokens":{"get":{"description":" [internal route ID: \"auth-tokens-list\"]\n\n","operationId":"auth-tokens-list","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ScimTokenList_NjQwNTYxOTAw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}},"post":{"description":" [internal route ID: \"auth-tokens-create\"]\n\n","operationId":"auth-tokens-create","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateScimToken_OTY0NjYxMDQ2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateScimTokenResponse_LTIzOTU2NDU4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}},"delete":{"description":" [internal route ID: \"auth-tokens-delete\"]\n\n","operationId":"auth-tokens-delete","parameters":[{"in":"query","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}}},"/scim/auth-tokens/{id}":{"put":{"description":" [internal route ID: \"auth-tokens-put-name\"]\n\n","operationId":"auth-tokens-put-name","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ScimTokenName_LTgzOTM2OTI4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}}},"/bot/assets":{"post":{"summary":"Upload an asset","description":" [internal route ID: (\"assets-upload-v3\", bot)]\n\n

Construct the request as multipart/mixed; set header Content-Type: multipart/mixed; boundary=<boundary>.

Use exactly two parts in this order:

  1. application/json metadata (AssetSettings)
  2. application/octet-stream asset bytes

Each part must include Content-Type and Content-Length; the second part may include Content-MD5. Use CRLF between headers and bodies.

When asset audit logging is enabled, the JSON metadata must include:

  • convId: object { id: UUID, domain: String } (qualified conversation ID)
  • filename: String
  • filetype: String MIME type (e.g. image/png, application/pdf)

Optional metadata: public (Bool, default false), retention (one of eternal, persistent, volatile, eternal-infrequent_access, expiring).

For profile pictures or team icons without a conversation, set convId.id to 00000000-0000-0000-0000-000000000000 and convId.domain to the tenant’s domain; use any reasonable filename.

Note: the server treats the asset bytes as application/octet-stream; filetype is used for auditing only.

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

--frontier
Content-Type: application/json
Content-Length: 191

{\"public\":false,\"retention\":\"volatile\",\"convId\":{\"id\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"domain\":\"example.com\"},\"filename\":\"report.pdf\",\"filetype\":\"application/pdf\"}
--frontier
Content-Type: application/octet-stream
Content-Length: 11

Hello Audit
--frontier--
","operationId":"assets-upload-v3_bot","requestBody":{"content":{"multipart/mixed":{"schema":{"$ref":"#/components/schemas/AssetSource"}}},"description":"A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server."},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}}},"description":"Asset posted","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"incomplete-body","message":"HTTP content-length header does not match body size"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["incomplete-body","invalid-length"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nHTTP content-length header does not match body size (label: `incomplete-body`)\n\nInvalid content length (label: `invalid-length`)"},"413":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"client-error","message":"Asset too large"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["client-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Asset too large (label: `client-error`)"}}}},"/bot/assets/{key}":{"get":{"summary":"Download an asset","description":" [internal route ID: (\"assets-download-v3\", bot)]\n\n","operationId":"assets-download-v3_bot","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}},{"in":"header","name":"Asset-Token","required":false,"schema":{"type":"string"}},{"in":"query","name":"asset_token","required":false,"schema":{"type":"string"}}],"responses":{"302":{"description":"Asset found","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` or Asset not found (label: `not-found`)"}}},"delete":{"summary":"Delete an asset","description":" [internal route ID: (\"assets-delete-v3\", bot)]\n\n","operationId":"assets-delete-v3_bot","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` not found\n\nAsset not found (label: `not-found`)"}}}},"/provider/assets":{"post":{"summary":"Upload an asset","description":" [internal route ID: (\"assets-upload-v3\", provider)]\n\n

Construct the request as multipart/mixed; set header Content-Type: multipart/mixed; boundary=<boundary>.

Use exactly two parts in this order:

  1. application/json metadata (AssetSettings)
  2. application/octet-stream asset bytes

Each part must include Content-Type and Content-Length; the second part may include Content-MD5. Use CRLF between headers and bodies.

When asset audit logging is enabled, the JSON metadata must include:

  • convId: object { id: UUID, domain: String } (qualified conversation ID)
  • filename: String
  • filetype: String MIME type (e.g. image/png, application/pdf)

Optional metadata: public (Bool, default false), retention (one of eternal, persistent, volatile, eternal-infrequent_access, expiring).

For profile pictures or team icons without a conversation, set convId.id to 00000000-0000-0000-0000-000000000000 and convId.domain to the tenant’s domain; use any reasonable filename.

Note: the server treats the asset bytes as application/octet-stream; filetype is used for auditing only.

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

--frontier
Content-Type: application/json
Content-Length: 191

{\"public\":false,\"retention\":\"volatile\",\"convId\":{\"id\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"domain\":\"example.com\"},\"filename\":\"report.pdf\",\"filetype\":\"application/pdf\"}
--frontier
Content-Type: application/octet-stream
Content-Length: 11

Hello Audit
--frontier--
","operationId":"assets-upload-v3_provider","requestBody":{"content":{"multipart/mixed":{"schema":{"$ref":"#/components/schemas/AssetSource"}}},"description":"A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server."},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}}},"description":"Asset posted","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"incomplete-body","message":"HTTP content-length header does not match body size"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["incomplete-body","invalid-length"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nHTTP content-length header does not match body size (label: `incomplete-body`)\n\nInvalid content length (label: `invalid-length`)"},"413":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"client-error","message":"Asset too large"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["client-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Asset too large (label: `client-error`)"}}}},"/provider/assets/{key}":{"get":{"summary":"Download an asset","description":" [internal route ID: (\"assets-download-v3\", provider)]\n\n","operationId":"assets-download-v3_provider","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}},{"in":"header","name":"Asset-Token","required":false,"schema":{"type":"string"}},{"in":"query","name":"asset_token","required":false,"schema":{"type":"string"}}],"responses":{"302":{"description":"Asset found","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` or Asset not found (label: `not-found`)"}}},"delete":{"summary":"Delete an asset","description":" [internal route ID: (\"assets-delete-v3\", provider)]\n\n","operationId":"assets-delete-v3_provider","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` not found\n\nAsset not found (label: `not-found`)"}}}},"/assets/{key}/token":{"post":{"summary":"Renew an asset token","description":" [internal route ID: \"tokens-renew\"]\n\n","operationId":"tokens-renew","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewAssetToken_NTAwMDQwODYy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` not found\n\nAsset not found (label: `not-found`)"}}},"delete":{"summary":"Delete an asset token","description":" [internal route ID: \"tokens-delete\"]\n\n**Note**: deleting the token makes the asset public.","operationId":"tokens-delete","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset token deleted"}}}},"/assets":{"post":{"summary":"Upload an asset","description":" [internal route ID: \"assets-upload\"]\n\n

Construct the request as multipart/mixed; set header Content-Type: multipart/mixed; boundary=<boundary>.

Use exactly two parts in this order:

  1. application/json metadata (AssetSettings)
  2. application/octet-stream asset bytes

Each part must include Content-Type and Content-Length; the second part may include Content-MD5. Use CRLF between headers and bodies.

When asset audit logging is enabled, the JSON metadata must include:

  • convId: object { id: UUID, domain: String } (qualified conversation ID)
  • filename: String
  • filetype: String MIME type (e.g. image/png, application/pdf)

Optional metadata: public (Bool, default false), retention (one of eternal, persistent, volatile, eternal-infrequent_access, expiring).

For profile pictures or team icons without a conversation, set convId.id to 00000000-0000-0000-0000-000000000000 and convId.domain to the tenant’s domain; use any reasonable filename.

Note: the server treats the asset bytes as application/octet-stream; filetype is used for auditing only.

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

--frontier
Content-Type: application/json
Content-Length: 191

{\"public\":false,\"retention\":\"volatile\",\"convId\":{\"id\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"domain\":\"example.com\"},\"filename\":\"report.pdf\",\"filetype\":\"application/pdf\"}
--frontier
Content-Type: application/octet-stream
Content-Length: 11

Hello Audit
--frontier--
","operationId":"assets-upload","requestBody":{"content":{"multipart/mixed":{"schema":{"$ref":"#/components/schemas/AssetSource"}}},"description":"A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server."},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}}},"description":"Asset posted","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"incomplete-body","message":"HTTP content-length header does not match body size"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["incomplete-body","invalid-length"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nHTTP content-length header does not match body size (label: `incomplete-body`)\n\nInvalid content length (label: `invalid-length`)"},"413":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"client-error","message":"Asset too large"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["client-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Asset too large (label: `client-error`)"}}}},"/assets/{key_domain}/{key}":{"get":{"summary":"Download an asset","description":" [internal route ID: \"assets-download\"]\n\n**Note**: local assets result in a redirect, while remote assets are streamed directly.","operationId":"assets-download","parameters":[{"in":"path","name":"key_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"key","required":true,"schema":{"type":"string"}},{"in":"header","name":"Asset-Token","required":false,"schema":{"type":"string"}},{"in":"query","name":"asset_token","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset returned directly with content type `application/octet-stream`"},"302":{"description":"Asset found","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key_domain` or `key` or Asset not found (label: `not-found`)\n\nAsset not found (label: `not-found`)"}}},"delete":{"summary":"Delete an asset","description":" [internal route ID: \"assets-delete\"]\n\n**Note**: only local assets can be deleted.","operationId":"assets-delete","parameters":[{"in":"path","name":"key_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key_domain` or `key` not found\n\nAsset not found (label: `not-found`)"}}}},"/await":{"get":{"summary":"Establish websocket connection","description":" [internal route ID: \"await-notifications\"]\n\n","externalDocs":{"description":"RFC 6455","url":"https://datatracker.ietf.org/doc/html/rfc6455"},"operationId":"await-notifications","parameters":[{"description":"Client ID","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"101":{"description":"Connection upgraded."},"426":{"description":"Upgrade required."}}}},"/websocket":{"get":{"summary":"Establish websocket connection","description":" [internal route ID: \"websocket\"]\n\nThis is a temporary copy of await, please do not use it","externalDocs":{"description":"RFC 6455","url":"https://datatracker.ietf.org/doc/html/rfc6455"},"operationId":"websocket","parameters":[{"description":"Client ID","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"101":{"description":"Connection upgraded."},"426":{"description":"Upgrade required."}}}},"/events":{"get":{"summary":"Consume events over a websocket connection","description":" [internal route ID: \"consume-events\"]\n\nThis is the rabbitMQ-based variant of \"await-notifications\"","externalDocs":{"description":"RFC 6455","url":"https://datatracker.ietf.org/doc/html/rfc6455"},"operationId":"consume-events","parameters":[{"description":"Client ID","in":"query","name":"client","required":false,"schema":{"type":"string"}},{"description":"Synchronization marker ID","in":"query","name":"sync_marker","required":false,"schema":{"type":"string"}}],"responses":{"101":{"description":"Connection upgraded."},"426":{"description":"Upgrade required."}}}},"/push/tokens":{"get":{"summary":"List the user's registered push tokens","description":" [internal route ID: \"get-push-tokens\"]\n\n","operationId":"get-push-tokens","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PushTokenList_NDI0Mjc3MzY3"}}},"description":""}}},"post":{"summary":"Register a native push token","description":" [internal route ID: \"register-push-token\"]\n\n","operationId":"register-push-token","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"}}},"description":"Push token registered","headers":{"Location":{"schema":{"type":"string"}}}},"400":{"content":{"application/json":{"schema":{"example":{"code":400,"label":"apns-voip-not-supported","message":"Adding APNS_VOIP tokens is not supported"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["apns-voip-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"apns-voip-not-supported","message":"Adding APNS_VOIP tokens is not supported"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["apns-voip-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Adding APNS_VOIP tokens is not supported (label: `apns-voip-not-supported`) or `body`"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"app-not-found","message":"App does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["app-not-found","invalid-token"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"app-not-found","message":"App does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["app-not-found","invalid-token"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"App does not exist (label: `app-not-found`)\n\nInvalid push token (label: `invalid-token`)"},"413":{"content":{"application/json":{"schema":{"example":{"code":413,"label":"sns-thread-budget-reached","message":"Too many concurrent calls to SNS; is SNS down?"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["sns-thread-budget-reached","token-too-long","metadata-too-long"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"sns-thread-budget-reached","message":"Too many concurrent calls to SNS; is SNS down?"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["sns-thread-budget-reached","token-too-long","metadata-too-long"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many concurrent calls to SNS; is SNS down? (label: `sns-thread-budget-reached`)\n\nPush token length must be < 8192 for GCM or 400 for APNS (label: `token-too-long`)\n\nTried to add token to endpoint resulting in metadata length > 2048 (label: `metadata-too-long`)"}}}},"/push/tokens/{pid}":{"delete":{"summary":"Unregister a native push token","description":" [internal route ID: \"delete-push-token\"]\n\n","operationId":"delete-push-token","parameters":[{"description":"The push token to delete","in":"path","name":"pid","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Push token unregistered"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Push token not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Push token not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`pid` or Push token not found (label: `not-found`)"}}}},"/notifications/{id}":{"get":{"summary":"Fetch a notification by ID","description":" [internal route ID: \"get-notification-by-id\"]\n\n","operationId":"get-notification-by-id","parameters":[{"description":"Notification ID","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Only return notifications targeted at the given client","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}}},"description":"Notification found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`id` or Some notifications not found (label: `not-found`)"}}}},"/notifications/last":{"get":{"summary":"Fetch the last notification","description":" [internal route ID: \"get-last-notification\"]\n\n","operationId":"get-last-notification","parameters":[{"description":"Only return notifications targeted at the given client","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}}},"description":"Notification found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Some notifications not found (label: `not-found`)"}}}},"/notifications":{"get":{"summary":"Fetch notifications","description":" [internal route ID: \"get-notifications\"]\n\nSee also: GET /teams/notifications","operationId":"get-notifications","parameters":[{"description":"Only return notifications more recent than this","in":"query","name":"since","required":false,"schema":{"format":"uuid","type":"string"}},{"description":"Only return notifications targeted at the given client","in":"query","name":"client","required":false,"schema":{"type":"string"}},{"description":"Maximum number of notifications to return","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":10000,"minimum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueuedNotificationList_MTU0ODEyNTQ2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotificationList_MTU0ODEyNTQ2"}}},"description":"Notification list"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Some notifications not found (label: `not-found`)"}}}},"/time":{"get":{"summary":"Get the current server time","description":" [internal route ID: \"get-server-time\"]\n\nReturns the current server time in UTC with seconds precision.","operationId":"get-server-time","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServerTime_LTM4NTI3MzIx"}}},"description":""}}}},"/proxy/giphy/v1/gifs":{},"/proxy/youtube/v3":{},"/proxy/googlemaps/api/staticmap":{},"/proxy/googlemaps/maps/api/geocode":{},"/proxy/spotify/api/token":{},"/proxy/soundcloud/resolve":{},"/proxy/soundcloud/stream":{}},"components":{"schemas":{"VersionInfo_NTEzMTgzNDQ0":{"example":{"development":[17],"domain":"example.com","federation":false,"supported":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17]},"properties":{"development":{"items":{"$ref":"#/components/schemas/VersionNumber_Njk2NzI5Njk1"},"type":"array"},"domain":{"$ref":"#/components/schemas/Domain"},"federation":{"type":"boolean"},"supported":{"items":{"$ref":"#/components/schemas/VersionNumber_Njk2NzI5Njk1"},"type":"array"}},"required":["supported","development","federation","domain"],"type":"object"},"VersionNumber_Njk2NzI5Njk1":{"enum":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17],"type":"integer"},"Domain":{"example":"example.com","type":"string"},"UserProfile_LTQzMTQxMTE1":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"app":{"$ref":"#/components/schemas/AppInfo_MjgwNTkwOTUz"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"contact_status":{"$ref":"#/components/schemas/ContactStatus_LTUzNzk1MzM4"},"deleted":{"type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"expires_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"handle":{"$ref":"#/components/schemas/Handle"},"id":{"$ref":"#/components/schemas/UUID"},"legalhold_status":{"$ref":"#/components/schemas/UserLegalHoldStatus_LTQ2ODA2NTU5"},"name":{"maxLength":128,"minLength":1,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"searchable":{"type":"boolean"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"},"team":{"$ref":"#/components/schemas/UUID"},"text_status":{"maxLength":256,"minLength":1,"type":"string"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"}},"required":["qualified_id","name","accent_id","legalhold_status"],"type":"object"},"UUID":{"description":"The OAuth client's ID","example":"99db9768-04e3-4b5d-9268-831b6a25c4ab","format":"uuid","type":"string"},"Qualified_Id_IdTag_User_LTQ1NTIwNDM1":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","id"],"type":"object"},"KeyMap_Value_MzAxODEwOTgx":{"type":"object"},"Pict_DEPRECATED_USE_ASSETS_INSTEAD":{"items":{"type":"object"},"maxItems":10,"minItems":0,"type":"array"},"AssetKey":{"description":"S3 asset key for an icon image with retention information.","example":"3-1-47de4580-ae51-4650-acbb-d10c028cb0ac","type":"string"},"AssetSize_OTAwMDA3ODY2":{"enum":["preview","complete"],"type":"string"},"MTYxOTI3NjM3":{"enum":["image"],"type":"string"},"Asset_LTIyMjc1NDEz":{"properties":{"key":{"$ref":"#/components/schemas/AssetKey"},"size":{"$ref":"#/components/schemas/AssetSize_OTAwMDA3ODY2"},"type":{"$ref":"#/components/schemas/MTYxOTI3NjM3"}},"required":["key","type"],"type":"object"},"ServiceRef_LTgxMjY3NzAz":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"provider":{"$ref":"#/components/schemas/UUID"}},"required":["id","provider"],"type":"object"},"Handle":{"type":"string"},"UTCTimeMillis":{"description":"The time when the session was created","example":"2021-05-12T10:52:02.671Z","format":"yyyy-mm-ddThh:MM:ss.qqqZ","type":"string"},"Email":{"type":"string"},"UserLegalHoldStatus_LTQ2ODA2NTU5":{"description":"The state of Legal Hold compliance for the member","enum":["enabled","pending","disabled","no_consent"],"type":"string"},"BaseProtocolTag_LTM0MDE1NTEx":{"enum":["proteus","mls"],"type":"string"},"UserType_LTU1OTU4OTM5":{"enum":["regular","app","bot"],"type":"string"},"AppInfo_MjgwNTkwOTUz":{"properties":{"category":{"description":"Category name (if uncertain, pick \"other\")","type":"string"},"description":{"maxLength":300,"minLength":0,"type":"string"}},"required":["category","description"],"type":"object"},"ContactStatusState_LTg2MjAyNzAx":{"enum":["contactable","non-contactable"],"type":"string"},"ContactStatus_LTUzNzk1MzM4":{"properties":{"state":{"$ref":"#/components/schemas/ContactStatusState_LTg2MjAyNzAx"}},"required":["state"],"type":"object"},"EmailUpdate_NjQ5MDg1OTY0":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"ListUsersById_LTQ5MTE3NDc0":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"minItems":1,"type":"array"},"found":{"items":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"},"type":"array"}},"required":["found"],"type":"object"},"ListUsersQuery":{"description":"exactly one of qualified_ids or qualified_handles must be provided.","example":{"qualified_ids":[{"domain":"example.com","id":"00000000-0000-0000-0000-000000000000"}]},"properties":{"qualified_handles":{"items":{"$ref":"#/components/schemas/Qualified_Handle_Nzg0MDE3Nzk4"},"type":"array"},"qualified_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"type":"object"},"Qualified_Handle_Nzg0MDE3Nzk4":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"handle":{"$ref":"#/components/schemas/Handle"}},"required":["domain","handle"],"type":"object"},"SendVerificationCode_MjgxNDgxODE2":{"properties":{"action":{"$ref":"#/components/schemas/VerificationAction_LTU0MzYxNzUz"},"email":{"$ref":"#/components/schemas/Email"}},"required":["action","email"],"type":"object"},"VerificationAction_LTU0MzYxNzUz":{"enum":["create_scim_token","login","delete_team"],"type":"string"},"RichInfoAssocList":{"description":"json object with case-insensitive fields.","properties":{"fields":{"items":{"$ref":"#/components/schemas/RichField_LTgwMzc0MTg2"},"type":"array"},"version":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["version","fields"],"type":"object"},"RichField_LTgwMzc0MTg2":{"properties":{"type":{"type":"string"},"value":{"type":"string"}},"required":["type","value"],"type":"object"},"SetSearchable_NDAxODAxODI5":{"properties":{"set_searchable":{"type":"boolean"}},"required":["set_searchable"],"type":"object"},"User_NjA4OTQwMTQ4":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"deleted":{"type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"email_unvalidated":{"$ref":"#/components/schemas/Email"},"expires_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"handle":{"$ref":"#/components/schemas/Handle"},"id":{"$ref":"#/components/schemas/UUID"},"locale":{"$ref":"#/components/schemas/Locale"},"managed_by":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"name":{"maxLength":128,"minLength":1,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"searchable":{"type":"boolean"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"sso_id":{"$ref":"#/components/schemas/UserSSOId"},"status":{"$ref":"#/components/schemas/AccountStatus_NzkzNDU1ODU5"},"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"},"team":{"$ref":"#/components/schemas/UUID"},"text_status":{"maxLength":256,"minLength":1,"type":"string"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"}},"required":["qualified_id","type","name","accent_id","status","locale"],"type":"object"},"UserSSOId":{"properties":{"scim_external_id":{"type":"string"},"subject":{"type":"string"},"tenant":{"type":"string"}},"type":"object"},"AccountStatus_NzkzNDU1ODU5":{"enum":["active","suspended","deleted","ephemeral","pending-invitation"],"type":"string"},"Locale":{"type":"string"},"ManagedBy_NTI0ODc0NTQx":{"enum":["wire","scim"],"type":"string"},"DeletionCodeTimeout_LTU1MTk0NDI3":{"properties":{"expires_in":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["expires_in"],"type":"object"},"DeleteUser_NjE0MjE2Mjkz":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"UserUpdate_MjQ4NTEwOTQz":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"name":{"maxLength":128,"minLength":1,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"text_status":{"maxLength":256,"minLength":1,"type":"string"}},"type":"object"},"PasswordChange_MTgzMDM2NTY2":{"description":"Data to change a password. The old password is required if a password already exists.","properties":{"new_password":{"maxLength":1024,"minLength":8,"type":"string"},"old_password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["new_password"],"type":"object"},"LocaleUpdate_LTgzNjgyOTEw":{"properties":{"locale":{"$ref":"#/components/schemas/Locale"}},"required":["locale"],"type":"object"},"HandleUpdate_NTI4NDk1OTAx":{"properties":{"handle":{"type":"string"}},"required":["handle"],"type":"object"},"SupportedProtocolUpdate_LTE3Njk3MDM4":{"properties":{"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"}},"required":["supported_protocols"],"type":"object"},"CreateUserTeam_MzI4NDQ1Mzkw":{"properties":{"team_id":{"$ref":"#/components/schemas/UUID"},"team_name":{"type":"string"}},"required":["team_id","team_name"],"type":"object"},"BindingNewTeamUser_LTY0MDQxMDEw":{"properties":{"currency":{"$ref":"#/components/schemas/Alpha_LTE4NDUxNDQ4"},"icon":{"$ref":"#/components/schemas/Icon"},"icon_key":{"description":"The decryption key for the team icon S3 asset","maxLength":256,"minLength":1,"type":"string"},"name":{"description":"team name","maxLength":256,"minLength":1,"type":"string"}},"required":["name","icon"],"type":"object"},"Icon":{"description":"S3 asset key for an icon image with retention information. Allows special value 'default'.","example":"3-1-47de4580-ae51-4650-acbb-d10c028cb0ac","type":"string"},"Alpha_LTE4NDUxNDQ4":{"description":"ISO 4217 alphabetic codes. This is only stored by the backend, not processed. It can be removed once billing supports currency changes after team creation.","enum":["AED","AFN","ALL","AMD","ANG","AOA","ARS","AUD","AWG","AZN","BAM","BBD","BDT","BGN","BHD","BIF","BMD","BND","BOB","BOV","BRL","BSD","BTN","BWP","BYN","BZD","CAD","CDF","CHE","CHF","CHW","CLF","CLP","CNY","COP","COU","CRC","CUC","CUP","CVE","CZK","DJF","DKK","DOP","DZD","EGP","ERN","ETB","EUR","FJD","FKP","GBP","GEL","GHS","GIP","GMD","GNF","GTQ","GYD","HKD","HNL","HRK","HTG","HUF","IDR","ILS","INR","IQD","IRR","ISK","JMD","JOD","JPY","KES","KGS","KHR","KMF","KPW","KRW","KWD","KYD","KZT","LAK","LBP","LKR","LRD","LSL","LYD","MAD","MDL","MGA","MKD","MMK","MNT","MOP","MRO","MUR","MVR","MWK","MXN","MXV","MYR","MZN","NAD","NGN","NIO","NOK","NPR","NZD","OMR","PAB","PEN","PGK","PHP","PKR","PLN","PYG","QAR","RON","RSD","RUB","RWF","SAR","SBD","SCR","SDG","SEK","SGD","SHP","SLL","SOS","SRD","SSP","STD","SVC","SYP","SZL","THB","TJS","TMT","TND","TOP","TRY","TTD","TWD","TZS","UAH","UGX","USD","USN","UYI","UYU","UZS","VEF","VND","VUV","WST","XAF","XAG","XAU","XBA","XBB","XBC","XBD","XCD","XDR","XOF","XPD","XPF","XPT","XSU","XTS","XUA","XXX","YER","ZAR","ZMW","ZWL"],"example":"EUR","type":"string"},"NewUser_PlainTextPassword_8_LTI4MzI5NzQx":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"email":{"$ref":"#/components/schemas/Email"},"email_code":{"$ref":"#/components/schemas/ASCII"},"expires_in":{"maximum":604800,"minimum":1,"type":"integer"},"invitation_code":{"$ref":"#/components/schemas/ASCII"},"label":{"type":"string"},"locale":{"$ref":"#/components/schemas/Locale"},"managed_by":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"name":{"maxLength":128,"minLength":1,"type":"string"},"password":{"maxLength":1024,"minLength":8,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"sso_id":{"$ref":"#/components/schemas/UserSSOId"},"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"},"team":{"$ref":"#/components/schemas/BindingNewTeamUser_LTY0MDQxMDEw"},"team_code":{"$ref":"#/components/schemas/ASCII"},"team_id":{"$ref":"#/components/schemas/UUID"},"uuid":{"$ref":"#/components/schemas/UUID"}},"required":["name"],"type":"object"},"ASCII":{"example":"aGVsbG8","type":"string"},"VerifyDeleteUser_Njc1NDQ1MDIy":{"description":"Data for verifying an account deletion.","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"}},"required":["key","code"],"type":"object"},"ActivationResponse_LTIyOTY5NDE3":{"description":"Response body of a successful activation request","properties":{"email":{"$ref":"#/components/schemas/Email"},"first":{"description":"Whether this is the first successful activation (i.e. account activation).","type":"boolean"},"sso_id":{"$ref":"#/components/schemas/UserSSOId"}},"type":"object"},"Activate_MzUzNzIxODUw":{"description":"Data for an activation request.","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"dryrun":{"description":"At least one of key, email, or phone has to be present while key takes precedence over email, and email takes precedence over phone. Whether to perform a dryrun, i.e. to only check whether activation would succeed. Dry-runs never issue access cookies or tokens on success but failures still count towards the maximum failure count.","type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"key":{"$ref":"#/components/schemas/ASCII"}},"required":["code","dryrun"],"type":"object"},"SendActivationCode_LTgyNDAxNzEy":{"description":"Data for requesting an email code to be sent. 'email' must be present.","properties":{"email":{"$ref":"#/components/schemas/Email"},"locale":{"$ref":"#/components/schemas/Locale"}},"required":["email"],"type":"object"},"NewPasswordReset_LTEyNzAxMTcy":{"description":"Data to initiate a password reset","properties":{"email":{"$ref":"#/components/schemas/Email"},"phone":{"description":"Email","type":"string"}},"type":"object"},"CompletePasswordReset_NDcyMjY5OTc4":{"description":"Data to complete a password reset","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"email":{"$ref":"#/components/schemas/Email"},"key":{"$ref":"#/components/schemas/ASCII"},"password":{"description":"New password (6 - 1024 characters)","maxLength":1024,"minLength":8,"type":"string"},"phone":{"$ref":"#/components/schemas/PhoneNumber"}},"required":["code","password"],"type":"object"},"PhoneNumber":{"description":"A known phone number with a pending password reset.","type":"string"},"PubClient":{"properties":{"class":{"$ref":"#/components/schemas/ClientClass_NjE3MDgwNzcx"},"id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"}},"required":["id"],"type":"object"},"ClientClass_NjE3MDgwNzcx":{"enum":["phone","tablet","desktop","legalhold"],"type":"string"},"QualifiedUserMap_Set_PubClient":{"additionalProperties":{"$ref":"#/components/schemas/UserMap_Set_PubClient"},"description":"Map of Domain to (UserMap (Set_PubClient)).","example":{"domain1.example.com":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":[{"class":"legalhold","id":"d0"}]}},"type":"object"},"UserMap_Set_PubClient":{"additionalProperties":{"items":{"$ref":"#/components/schemas/PubClient"},"type":"array","uniqueItems":true},"description":"Map of UserId to (Set PubClient)","example":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":[{"class":"legalhold","id":"d0"}]},"type":"object"},"LimitedQualifiedUserIdList_500":{"properties":{"qualified_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["qualified_users"],"type":"object"},"ClientPrekey_LTcyODUzMTcw":{"properties":{"client":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"prekey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"}},"required":["client","prekey"],"type":"object"},"UncheckedPrekeyBundle_LTU1MzQzOTgy":{"properties":{"id":{"maximum":65535,"minimum":0,"type":"integer"},"key":{"type":"string"}},"required":["id","key"],"type":"object"},"PrekeyBundle_MzgzOTk4MjYz":{"properties":{"clients":{"items":{"$ref":"#/components/schemas/ClientPrekey_LTcyODUzMTcw"},"type":"array"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","clients"],"type":"object"},"QualifiedUserClientPrekeyMapV4_MzcxOTAxMTYy":{"properties":{"failed_to_list":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"qualified_user_client_prekeys":{"additionalProperties":{"$ref":"#/components/schemas/UserClientPrekeyMap"},"type":"object"}},"required":["qualified_user_client_prekeys"],"type":"object"},"UserClientPrekeyMap":{"additionalProperties":{"additionalProperties":{"properties":{"id":{"maximum":65535,"minimum":0,"type":"integer"},"key":{"type":"string"}},"required":["id","key"],"type":"object"},"type":"object"},"example":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":{"44901fb0712e588f":{"id":1,"key":"pQABAQECoQBYIOjl7hw0D8YRNq..."}}},"type":"object"},"QualifiedUserClients":{"additionalProperties":{"additionalProperties":{"items":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"type":"array"},"type":"object"},"description":"Map of Domain to UserClients","example":{"domain1.example.com":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":["60f85e4b15ad3786","6e323ab31554353b"]}},"type":"object"},"Client_MTM1OTcwOTQ1":{"properties":{"capabilities":{"$ref":"#/components/schemas/ClientCapabilityList"},"class":{"$ref":"#/components/schemas/ClientClass_NjE3MDgwNzcx"},"cookie":{"type":"string"},"id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"label":{"type":"string"},"last_active":{"$ref":"#/components/schemas/UTCTime"},"mls_public_keys":{"$ref":"#/components/schemas/MLSPublicKeys"},"model":{"type":"string"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"},"type":{"$ref":"#/components/schemas/ClientType_MjQ0OTQwMzcw"}},"required":["id","type","time"],"type":"object"},"ClientType_MjQ0OTQwMzcw":{"enum":["temporary","permanent","legalhold"],"type":"string"},"ClientCapability_MTY2NDAzMjM3":{"enum":["legalhold-implicit-consent","consumable-notifications"],"type":"string"},"ClientCapabilityList":{"items":{"$ref":"#/components/schemas/ClientCapability_MTY2NDAzMjM3"},"type":"array"},"Base64ByteString":{"example":"ZXhhbXBsZQo=","type":"string"},"MLSPublicKeys":{"additionalProperties":{"example":"ZXhhbXBsZQo=","type":"string"},"description":"Mapping from signature scheme (tags) to public key data","example":{"ecdsa_secp256r1_sha256":"ZXhhbXBsZQo=","ecdsa_secp384r1_sha384":"ZXhhbXBsZQo=","ecdsa_secp521r1_sha512":"ZXhhbXBsZQo=","ed25519":"ZXhhbXBsZQo="},"type":"object"},"UTCTime":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"},"NewClient_ODg1NjY4Njgy":{"properties":{"capabilities":{"$ref":"#/components/schemas/ClientCapabilityList"},"class":{"$ref":"#/components/schemas/ClientClass_NjE3MDgwNzcx"},"cookie":{"description":"The cookie label, i.e. the label used when logging in.","type":"string"},"label":{"type":"string"},"lastkey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"mls_public_keys":{"$ref":"#/components/schemas/MLSPublicKeys"},"model":{"type":"string"},"password":{"description":"The password of the authenticated user for verification. Note: Required for registration of the 2nd, 3rd, ... client.","maxLength":1024,"minLength":6,"type":"string"},"prekeys":{"description":"Prekeys for other clients to establish OTR sessions.","items":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"type":"array"},"type":{"$ref":"#/components/schemas/ClientType_MjQ0OTQwMzcw"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"required":["prekeys","lastkey","type"],"type":"object"},"UpdateClient_NzU5MjA4MzI1":{"properties":{"capabilities":{"$ref":"#/components/schemas/ClientCapabilityList"},"label":{"description":"A new name for this client.","type":"string"},"lastkey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"mls_public_keys":{"$ref":"#/components/schemas/MLSPublicKeys"},"prekeys":{"description":"New prekeys for other clients to establish OTR sessions.","items":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"type":"array"}},"type":"object"},"RmClient_MTQ5OTI2MDY3":{"properties":{"password":{"description":"The password of the authenticated user for verification. The password is not required for deleting temporary clients. or if the user has only SAML credentials.","maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"DPoPAccessTokenResponse_LTgyODU5MDE3":{"properties":{"expires_in":{"format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"token":{"$ref":"#/components/schemas/DPoPAccessToken"},"type":{"$ref":"#/components/schemas/AccessTokenType_LTgyOTY0NDE5"}},"required":["token","type","expires_in"],"type":"object"},"DPoPAccessToken":{"type":"string"},"AccessTokenType_LTgyOTY0NDE5":{"enum":["DPoP"],"type":"string"},"UserConnection_LTY3NzU1ODg0":{"properties":{"conversation":{"$ref":"#/components/schemas/UUID"},"from":{"$ref":"#/components/schemas/UUID"},"last_update":{"$ref":"#/components/schemas/UTCTimeMillis"},"qualified_conversation":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_to":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"status":{"$ref":"#/components/schemas/Relation_LTE4OTU5MTk4"},"to":{"$ref":"#/components/schemas/UUID"}},"required":["from","qualified_to","status","last_update"],"type":"object"},"Relation_LTE4OTU5MTk4":{"enum":["accepted","blocked","pending","ignored","sent","cancelled","missing-legalhold-consent"],"type":"string"},"Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","id"],"type":"object"},"MultiTabePg_ConcsLOrRmU_MTgwMzU4OTU5":{"properties":{"connections":{"items":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"},"type":"array"},"has_more":{"type":"boolean"},"paging_state":{"$ref":"#/components/schemas/Connections_PagingState"}},"required":["connections","has_more","paging_state"],"type":"object"},"Connections_PagingState":{"type":"string"},"GetMuliTabPgRqs_ConcLOrm501_LTIwMjA4NzYw":{"description":"A request to list some or all of a user's Connections, including remote ones","properties":{"paging_state":{"$ref":"#/components/schemas/Connections_PagingState"},"size":{"description":"optional, must be <= 500, defaults to 100.","format":"int32","maximum":500,"minimum":1,"type":"integer"}},"type":"object"},"ConnectionUpdate_LTU3MTA1OTA5":{"properties":{"status":{"$ref":"#/components/schemas/Relation_LTE4OTU5MTk4"}},"required":["status"],"type":"object"},"SearchResult_Contact_OTExNzg4MTE0":{"properties":{"documents":{"description":"List of contacts found","items":{"$ref":"#/components/schemas/Contact_LTcwODE3Mjc5"},"type":"array"},"found":{"description":"Total number of hits","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"has_more":{"description":"Indicates whether there are more results to be fetched","type":"boolean"},"paging_state":{"$ref":"#/components/schemas/PagingState"},"returned":{"description":"Total number of hits returned","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"search_policy":{"$ref":"#/components/schemas/FederatedUserSearchPolicy_MzkwODA4MTM3"},"took":{"description":"Search time in ms","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["found","returned","took","documents","search_policy"],"type":"object"},"Contact_LTcwODE3Mjc5":{"description":"Contact discovered through search","properties":{"accent_id":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"handle":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"}},"required":["qualified_id","name","type"],"type":"object"},"FederatedUserSearchPolicy_MzkwODA4MTM3":{"description":"Search policy that was applied when searching for users","enum":["no_search","exact_handle_search","full_search"],"type":"string"},"PagingState":{"description":"Paging state that should be supplied to retrieve the next page of results","type":"string"},"PropertyValue":{"description":"An arbitrary JSON value for a property"},"PropertyKeysAndValues":{"type":"object"},"KeyPackageUpload_NTQ2Mjk2NzEx":{"properties":{"key_packages":{"items":{"$ref":"#/components/schemas/KeyPackage"},"type":"array"}},"required":["key_packages"],"type":"object"},"KeyPackage":{"example":"a2V5IHBhY2thZ2UgZGF0YQo=","type":"string"},"KeyPackageBundle_MjU2MjY0MDU2":{"properties":{"key_packages":{"items":{"$ref":"#/components/schemas/KeyPackageBundleEntry_NDQ2MzQ2MzMz"},"type":"array"}},"required":["key_packages"],"type":"object"},"KeyPackageRef":{"example":"ZXhhbXBsZQo=","type":"string"},"KeyPackageBundleEntry_NDQ2MzQ2MzMz":{"properties":{"client":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"domain":{"$ref":"#/components/schemas/Domain"},"key_package":{"$ref":"#/components/schemas/KeyPackage"},"key_package_ref":{"$ref":"#/components/schemas/KeyPackageRef"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["domain","user","client","key_package_ref","key_package"],"type":"object"},"KeyPackageCount_LTYwNDg5MDcz":{"properties":{"count":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["count"],"type":"object"},"DeleteKeyPackages_LTQxNTcxNjY3":{"properties":{"key_packages":{"items":{"$ref":"#/components/schemas/KeyPackageRef"},"maxItems":1000,"minItems":1,"type":"array"}},"required":["key_packages"],"type":"object"},"CheckHandles_LTc0OTkxMzAx":{"properties":{"handles":{"items":{"type":"string"},"maxItems":50,"minItems":1,"type":"array"},"return":{"maximum":10,"minimum":1,"type":"integer"}},"required":["handles","return"],"type":"object"},"SearchResult_TeamContact_LTE0NjQ0NzMw":{"properties":{"documents":{"description":"List of contacts found","items":{"$ref":"#/components/schemas/TeamContact_LTI5MTIxODc0"},"type":"array"},"found":{"description":"Total number of hits","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"has_more":{"description":"Indicates whether there are more results to be fetched","type":"boolean"},"paging_state":{"$ref":"#/components/schemas/PagingState"},"returned":{"description":"Total number of hits returned","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"search_policy":{"$ref":"#/components/schemas/FederatedUserSearchPolicy_MzkwODA4MTM3"},"took":{"description":"Search time in ms","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["found","returned","took","documents","search_policy"],"type":"object"},"Role_LTIzMjAzMjky":{"description":"Role of the invited user","enum":["owner","admin","member","partner"],"type":"string"},"Sso_LTg1MDM5ODQ3":{"properties":{"issuer":{"type":"string"},"nameid":{"type":"string"}},"required":["issuer","nameid"],"type":"object"},"TeamContact_LTI5MTIxODc0":{"properties":{"accent_id":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"email":{"$ref":"#/components/schemas/Email"},"email_unvalidated":{"$ref":"#/components/schemas/Email"},"handle":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"managed_by":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"name":{"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"},"saml_idp":{"type":"string"},"scim_external_id":{"type":"string"},"searchable":{"type":"boolean"},"sso":{"$ref":"#/components/schemas/Sso_LTg1MDM5ODQ3"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"},"user_groups":{"description":"List of user group ids the user is a member of","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["id","type","name","user_groups","searchable"],"type":"object"},"AccessToken_ODIyMTczMjMw":{"properties":{"access_token":{"description":"The opaque access token string","type":"string"},"expires_in":{"description":"The number of seconds this token is valid","type":"integer"},"token_type":{"$ref":"#/components/schemas/TokenType_NTkyMzk4MjIz"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","access_token","token_type","expires_in"],"type":"object"},"TokenType_NTkyMzk4MjIz":{"enum":["Bearer"],"type":"string"},"Login_LTgyNTIzMTM1":{"properties":{"email":{"$ref":"#/components/schemas/Email"},"handle":{"$ref":"#/components/schemas/Handle"},"label":{"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"required":["password"],"type":"object"},"CookieList_LTM4MzYwNzAz":{"description":"List of cookie information","properties":{"cookies":{"items":{"$ref":"#/components/schemas/Cookie_LTkyMDA3OTI5"},"type":"array"}},"required":["cookies"],"type":"object"},"CookieType_LTE0MjczNzY3":{"enum":["session","persistent"],"type":"string"},"Cookie_LTkyMDA3OTI5":{"properties":{"created":{"$ref":"#/components/schemas/UTCTime"},"expires":{"$ref":"#/components/schemas/UTCTime"},"id":{"format":"int32","maximum":4294967295,"minimum":0,"type":"integer"},"label":{"type":"string"},"successor":{"format":"int32","maximum":4294967295,"minimum":0,"type":"integer"},"type":{"$ref":"#/components/schemas/CookieType_LTE0MjczNzY3"}},"required":["id","type","created","expires"],"type":"object"},"RemoveCookies_OTYwMTI0NDMy":{"description":"Data required to remove cookies","properties":{"ids":{"description":"A list of cookie IDs to revoke","items":{"format":"int32","maximum":4294967295,"minimum":0,"type":"integer"},"type":"array"},"labels":{"description":"A list of cookie labels for which to revoke the cookies","items":{"type":"string"},"type":"array"},"password":{"description":"The user's password","maxLength":1024,"minLength":6,"type":"string"}},"required":["password"],"type":"object"},"RTCConfiguration_LTIwOTc4OTk0":{"description":"A subset of the WebRTC 'RTCConfiguration' dictionary","properties":{"ice_servers":{"description":"Array of 'RTCIceServer' objects","items":{"$ref":"#/components/schemas/RTCIceServer_LTY1NzExODA0"},"minItems":1,"type":"array"},"is_federating":{"description":"True if the client should connect to an SFT in the sft_servers_all and request it to federate","type":"boolean"},"sft_servers":{"description":"Array of 'SFTServer' objects (optional)","items":{"$ref":"#/components/schemas/SFTServer_NDQ0NDkwNDE2"},"minItems":1,"type":"array"},"sft_servers_all":{"description":"Array of all SFT servers","items":{"$ref":"#/components/schemas/AuthSFTServer_LTY5MzcyOTE0"},"type":"array"},"ttl":{"description":"Number of seconds after which the configuration should be refreshed (advisory)","format":"int32","maximum":4294967295,"minimum":0,"type":"integer"}},"required":["ice_servers","ttl"],"type":"object"},"TurnURI":{"type":"string"},"TurnUsername":{"description":"Username to use for authenticating against the given TURN servers","type":"string"},"RTCIceServer_LTY1NzExODA0":{"description":"A subset of the WebRTC 'RTCIceServer' object","properties":{"credential":{"$ref":"#/components/schemas/ASCII"},"urls":{"description":"Array of TURN server addresses of the form 'turn::'","items":{"$ref":"#/components/schemas/TurnURI"},"minItems":1,"type":"array"},"username":{"$ref":"#/components/schemas/TurnUsername"}},"required":["urls","username","credential"],"type":"object"},"HttpsUrl":{"example":"https://example.com","type":"string"},"SFTServer_NDQ0NDkwNDE2":{"description":"Inspired by WebRTC 'RTCIceServer' object, contains details of SFT servers","properties":{"urls":{"description":"Array containing exactly one SFT server address of the form 'https://:'","items":{"$ref":"#/components/schemas/HttpsUrl"},"type":"array"}},"required":["urls"],"type":"object"},"SFTUsername":{"description":"String containing the SFT username","type":"string"},"AuthSFTServer_LTY5MzcyOTE0":{"description":"Inspired by WebRTC 'RTCIceServer' object, contains details of SFT servers","properties":{"credential":{"$ref":"#/components/schemas/ASCII"},"urls":{"description":"Array containing exactly one SFT server address of the form 'https://:'","items":{"$ref":"#/components/schemas/HttpsUrl"},"type":"array"},"username":{"$ref":"#/components/schemas/SFTUsername"}},"required":["urls"],"type":"object"},"Invitation_NTkzMDYwODc1":{"description":"An invitation to join a team on Wire. If invitee is invited from an existing personal account, inviter email is included.","properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"email":{"$ref":"#/components/schemas/Email"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"description":"Name of the invitee (1 - 128 characters)","maxLength":128,"minLength":1,"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"},"team":{"$ref":"#/components/schemas/UUID"},"url":{"$ref":"#/components/schemas/URIRef_Absolute"}},"required":["team","id","created_at","email"],"type":"object"},"URIRef_Absolute":{"description":"URL of the invitation link to be sent to the invitee","type":"string"},"InvitationRequest_LTcyMDIzNDc0":{"description":"A request to join a team on Wire.","properties":{"allow_existing":{"description":"Whether invitations to existing users are allowed.","type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"locale":{"$ref":"#/components/schemas/Locale"},"name":{"description":"Name of the invitee (1 - 128 characters).","maxLength":128,"minLength":1,"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"}},"required":["email"],"type":"object"},"InvitationList_ODk4NTQxODc3":{"description":"A list of sent team invitations.","properties":{"has_more":{"description":"Indicator that the server has more invitations than returned.","type":"boolean"},"invitations":{"items":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"},"type":"array"}},"required":["invitations","has_more"],"type":"object"},"InvitationUserView_LTUyMTE3Nzkz":{"properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"created_by_email":{"$ref":"#/components/schemas/Email"},"email":{"$ref":"#/components/schemas/Email"},"id":{"$ref":"#/components/schemas/UUID"},"managed_by":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"name":{"description":"Name of the invitee (1 - 128 characters)","maxLength":128,"minLength":1,"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"},"team":{"$ref":"#/components/schemas/UUID"},"url":{"$ref":"#/components/schemas/URIRef_Absolute"}},"required":["team","id","created_at","email"],"type":"object"},"TeamSize_LTMzMzk2MTk1":{"description":"Team member counts broken down by user type.","properties":{"teamSize":{"description":"Total team members (teamSizeRegulars + teamSizeApps).","exclusiveMinimum":false,"minimum":0,"type":"integer"},"teamSizeApps":{"description":"Number of apps in team.","exclusiveMinimum":false,"minimum":0,"type":"integer"},"teamSizeRegulars":{"description":"Number of regular users in team.","exclusiveMinimum":false,"minimum":0,"type":"integer"}},"required":["teamSizeRegulars","teamSizeApps"],"type":"object"},"AcceptTeamInvitation_Nzg5NzI3MjA2":{"description":"Accept an invitation to join a team on Wire.","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"password":{"description":"The user account password.","maxLength":1024,"minLength":6,"type":"string"}},"required":["code","password"],"type":"object"},"SystemSettingsPublic_LTgwNTMxNjU2":{"properties":{"nomadProfiles":{"description":"Whether Nomad client profiles are enabled; null or absence means not enabled.","type":"boolean"},"setRestrictUserCreation":{"description":"Do not allow certain user creation flows","type":"boolean"}},"required":["setRestrictUserCreation"],"type":"object"},"SystemSettings_ODU3MDk5MTA3":{"properties":{"nomadProfiles":{"description":"Whether Nomad client profiles are enabled; null or absence means not enabled.","type":"boolean"},"setEnableMls":{"description":"Whether MLS is enabled or not","type":"boolean"},"setRestrictUserCreation":{"description":"Do not allow certain user creation flows","type":"boolean"}},"required":["setRestrictUserCreation","setEnableMls"],"type":"object"},"OAuthClient_NzExMTI5NTIy":{"properties":{"application_name":{"maxLength":256,"minLength":6,"type":"string"},"client_id":{"$ref":"#/components/schemas/UUID"},"redirect_url":{"$ref":"#/components/schemas/RedirectUrl"}},"required":["client_id","application_name","redirect_url"],"type":"object"},"RedirectUrl":{"description":"The URL must match the URL that was used to generate the authorization code.","type":"string"},"CreateOAuthAuthorizationCodeRequest_LTQ3NzI1Mjkz":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"code_challenge":{"$ref":"#/components/schemas/OAuthCodeChallenge"},"code_challenge_method":{"$ref":"#/components/schemas/CodeChallengeMethod_NTIxNzk0NDgw"},"redirect_uri":{"$ref":"#/components/schemas/RedirectUrl"},"response_type":{"$ref":"#/components/schemas/OAuthResponseType_ODI2Mjg3NzQx"},"scope":{"description":"The scopes which are requested to get authorization for, separated by a space","type":"string"},"state":{"description":"An opaque value used by the client to maintain state between the request and callback. The authorization server includes this value when redirecting the user-agent back to the client. The parameter SHOULD be used for preventing cross-site request forgery","type":"string"}},"required":["client_id","scope","response_type","redirect_uri","state","code_challenge_method","code_challenge"],"type":"object"},"OAuthResponseType_ODI2Mjg3NzQx":{"description":"Indicates which authorization flow to use. Use `code` for authorization code flow.","enum":["code"],"type":"string"},"CodeChallengeMethod_NTIxNzk0NDgw":{"description":"The method used to encode the code challenge. Only `S256` is supported.","enum":["S256"],"type":"string"},"OAuthCodeChallenge":{"description":"Generated by the client from the code verifier (unpadded base64url-encoded SHA256 hash of the code verifier)","type":"string"},"OAuthAccessTokenResponse_NzEwOTI4NjQ0":{"properties":{"access_token":{"description":"The access token, which has a relatively short lifetime","type":"string"},"expires_in":{"description":"The lifetime of the access token in seconds","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"refresh_token":{"description":"The refresh token, which has a relatively long lifetime, and can be used to obtain a new access token","type":"string"},"token_type":{"$ref":"#/components/schemas/OAuthAccessTokenType_MjU3ODI0NDIw"}},"required":["access_token","token_type","expires_in","refresh_token"],"type":"object"},"OAuthAccessTokenType_MjU3ODI0NDIw":{"description":"The type of the access token. Currently only `Bearer` is supported.","enum":["Bearer"],"type":"string"},"Either_OAuthAccessTokenRequest_OAuthRefreshAccessTokenRequest":{"oneOf":[{"properties":{"Left":{"$ref":"#/components/schemas/OAuthAccessTokenRequest_LTYyNTcyMzI4"}},"required":["Left"],"title":"Left","type":"object"},{"properties":{"Right":{"$ref":"#/components/schemas/OAuthRefreshAccessTokenRequest_LTUwNjA3OTI1"}},"required":["Right"],"title":"Right","type":"object"}]},"OAuthAccessTokenRequest_LTYyNTcyMzI4":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"code":{"$ref":"#/components/schemas/OAuthAuthorizationCode"},"code_verifier":{"description":"The code verifier to complete the code challenge","maxLength":128,"minLength":43,"type":"string"},"grant_type":{"$ref":"#/components/schemas/OAuthGrantType_LTIxODA5NDIw"},"redirect_uri":{"$ref":"#/components/schemas/RedirectUrl"}},"required":["grant_type","client_id","code_verifier","code","redirect_uri"],"type":"object"},"OAuthGrantType_LTIxODA5NDIw":{"description":"Indicates which authorization flow to use. Use `authorization_code` for authorization code flow.","enum":["authorization_code","refresh_token"],"type":"string"},"OAuthAuthorizationCode":{"description":"The authorization code","type":"string"},"OAuthRefreshAccessTokenRequest_LTUwNjA3OTI1":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"grant_type":{"$ref":"#/components/schemas/OAuthGrantType_LTIxODA5NDIw"},"refresh_token":{"description":"The refresh token","type":"string"}},"required":["grant_type","client_id","refresh_token"],"type":"object"},"OAuthRevokeRefreshTokenRequest_MjA1MDg4MzQ4":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"refresh_token":{"description":"The refresh token","type":"string"}},"required":["client_id","refresh_token"],"type":"object"},"OAuthApplication_Mjk5NTUxNjA1":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"name":{"description":"The OAuth client's name","maxLength":256,"minLength":6,"type":"string"},"sessions":{"description":"The OAuth client's sessions","items":{"$ref":"#/components/schemas/OAuthSession_LTQxOTIxNTMy"},"type":"array"}},"required":["id","name","sessions"],"type":"object"},"OAuthSession_LTQxOTIxNTMy":{"properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"refresh_token_id":{"$ref":"#/components/schemas/UUID"}},"required":["refresh_token_id","created_at"],"type":"object"},"PasswordReqBody_LTcxMzE3ODE3":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"AddBotResponse_ODA5MzA2NTA1":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"client":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"event":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"}},"required":["id","client","name","accent_id","assets","event"],"type":"object"},"EventType_LTQ3NTQyNDYz":{"enum":["conversation.member-join","conversation.member-leave","conversation.member-update","conversation.rename","conversation.access-update","conversation.receipt-mode-update","conversation.message-timer-update","conversation.code-update","conversation.code-delete","conversation.create","conversation.create-meeting","conversation.delete","conversation.delete-meeting","conversation.mls-reset","conversation.connect-request","conversation.typing","conversation.otr-message-add","conversation.mls-message-add","conversation.mls-welcome","conversation.protocol-update","conversation.add-permission-update","conversation.history-update","conversation.adminless-reminder"],"type":"string"},"RoleName":{"description":"Role name, between 2 and 128 chars, 'wire_' prefix is reserved for roles designed by Wire (i.e., no custom roles can have the same prefix)","type":"string"},"SimpleMember_NTY5MTcxMzcx":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"id":{"$ref":"#/components/schemas/UUID"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"}},"required":["qualified_id"],"type":"object"},"JoinType_LTY4MDg2MzA5":{"enum":["external_add","internal_add"],"type":"string"},"MembersJoin_LTg0MDc1NjQ3":{"properties":{"add_type":{"$ref":"#/components/schemas/JoinType_LTY4MDg2MzA5"},"user_ids":{"deprecated":true,"description":"deprecated","items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"users":{"items":{"$ref":"#/components/schemas/SimpleMember_NTY5MTcxMzcx"},"type":"array"}},"required":["users","add_type"],"type":"object"},"EdMemberLeftReason_OTAyMDA4NzEw":{"enum":["left","user-deleted","removed"],"type":"string"},"EdMemberLeftReason_QualifiedUserIdList_LTYyOTg2OTQ1":{"properties":{"qualified_user_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"reason":{"$ref":"#/components/schemas/EdMemberLeftReason_OTAyMDA4NzEw"},"user_ids":{"deprecated":true,"description":"Deprecated, use qualified_user_ids","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["reason","qualified_user_ids","user_ids"],"type":"object"},"MemberUpdateData_LTc3Nzc3NTEy":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"qualified_target":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"target":{"$ref":"#/components/schemas/UUID"}},"required":["qualified_target"],"type":"object"},"ConversationRename_ODkwODg1MzQ0":{"properties":{"name":{"description":"The new conversation name","type":"string"}},"required":["name"],"type":"object"},"Access_NjkyMzE5ODc0":{"description":"How users can join conversations","enum":["private","invite","link","code"],"type":"string"},"AccessRoleLegacy_LTYwOTAxMDI1":{"deprecated":true,"description":"Deprecated, please use access_role_v2","enum":["private","team","activated","non_activated"],"type":"string"},"AccessRole_Mzk3MDYzMzcw":{"description":"Which users/services can join conversations. This replaces legacy access roles and allows a more fine grained configuration of access roles, and in particular a separation of guest and services access.\n\nThis field is optional. If it is not present, the default will be `[team_member, non_team_member, service]`. Please note that an empty list is not allowed when creating a new conversation.","enum":["team_member","non_team_member","guest","service"],"type":"string"},"v2_ConversationAccessData_MjMxMTI5ODc3":{"properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"$ref":"#/components/schemas/AccessRoleLegacy_LTYwOTAxMDI1"},"access_role_v2":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"}},"required":["access"],"type":"object"},"ConversationMessageTimerUpdate_LTcxMjUwNzQ4":{"description":"Contains conversation properties to update","properties":{"message_timer":{"format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"type":"object"},"ConversationCodeInfo_LTc5MzgzNjg3":{"description":"Contains conversation properties to update","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"has_password":{"description":"Whether the conversation has a password","type":"boolean"},"key":{"$ref":"#/components/schemas/ASCII"},"uri":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["key","code","uri","has_password"],"type":"object"},"ConvType_MzM0NTE3ODE5":{"enum":[0,1,2,3],"type":"integer"},"GroupConvTypeLegacy_NTUxMDI2Mzkw":{"enum":["group_conversation","channel"],"type":"string"},"AddPermission_LTE1MzgzNzE3":{"enum":["admins","everyone"],"type":"string"},"CellsState_LTg4MDEwNDA5":{"enum":["disabled","pending","ready"],"type":"string"},"HistoryDuration":{"type":"string"},"HistorySharingConfig_Mjc4MzA1Nzgw":{"properties":{"depth":{"$ref":"#/components/schemas/HistoryDuration"}},"required":["depth"],"type":"object"},"History":{"properties":{"depth":{"$ref":"#/components/schemas/HistoryDuration"}},"required":["depth"],"type":"object"},"Member_OTA5OTgyNzcw":{"description":"The user ID of the requestor if the requestor is a member of the conversation","properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"status":{},"status_ref":{},"status_time":{}},"required":["qualified_id"],"type":"object"},"OtherMember_LTgzNzE2MTk4":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"id":{"$ref":"#/components/schemas/UUID"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"status":{"deprecated":true,"description":"deprecated","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["qualified_id"],"type":"object"},"OwnConvMembers_LTEwMzUzODMy":{"description":"Users of a conversation","properties":{"others":{"description":"All other current users of this conversation","items":{"$ref":"#/components/schemas/OtherMember_LTgzNzE2MTk4"},"type":"array"},"self":{"$ref":"#/components/schemas/Member_OTA5OTgyNzcw"}},"required":["self","others"],"type":"object"},"ProtocolTag_ODg1MTE5NjEw":{"enum":["proteus","mls","mixed"],"type":"string"},"GroupId":{"description":"A base64-encoded MLS group ID","example":"ZXhhbXBsZQo=","type":"string"},"EpochTimestamp":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"},"CipherSuiteTag":{"description":"The cipher suite of the corresponding MLS group","maximum":65535,"minimum":0,"type":"integer"},"v2_OwnConversation_GroupConvTypeLegacy_MjQ0OTcyNjQ3":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"$ref":"#/components/schemas/AccessRoleLegacy_LTYwOTAxMDI1"},"access_role_v2":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/EpochTimestamp"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvTypeLegacy_NTUxMDI2Mzkw"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","members","group_id","epoch","epoch_timestamp","cipher_suite"],"type":"object"},"GroupConvType_LTU4NjU0MTY5":{"enum":["group_conversation","channel","meeting"],"type":"string"},"v2_OwnConversation_GroupConvType_LTU2MzYxNTg0":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"$ref":"#/components/schemas/AccessRoleLegacy_LTYwOTAxMDI1"},"access_role_v2":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/EpochTimestamp"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","members","group_id","epoch","epoch_timestamp","cipher_suite"],"type":"object"},"Connect_ODY3OTE4NTYx":{"properties":{"email":{"type":"string"},"message":{"type":"string"},"name":{"type":"string"},"qualified_recipient":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"recipient":{"$ref":"#/components/schemas/UUID"}},"required":["qualified_recipient"],"type":"object"},"ConversationReset_MzU1Nzc5MjAw":{"properties":{"group_id":{"$ref":"#/components/schemas/GroupId"},"new_group_id":{"$ref":"#/components/schemas/GroupId"}},"required":["group_id"],"type":"object"},"ConversationReceiptModeUpdate_NDE4MzUzNTU3":{"description":"Contains conversation receipt mode to update to. Receipt mode tells clients whether certain types of receipts should be sent in the given conversation or not. How this value is interpreted is up to clients.","properties":{"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["receipt_mode"],"type":"object"},"OtrMessage_LTY4MTYzNzg3":{"description":"Encrypted message of a conversation","properties":{"data":{"description":"Extra (symmetric) data (i.e. ciphertext, Base64 in JSON) that is common with all other recipients.","type":"string"},"recipient":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"sender":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"text":{"description":"The ciphertext for the recipient (Base64 in JSON)","type":"string"}},"required":["sender","recipient","text"],"type":"object"},"TypingStatus_LTg5MzcyNDMy":{"enum":["started","stopped"],"type":"string"},"ProtocolUpdate_NzY1ODgxNDQy":{"properties":{"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"}},"type":"object"},"AddPermissionUpdate_LTU3MzEwOTY4":{"description":"The action of changing the permission to add members to a channel","properties":{"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"}},"required":["add_permission"],"type":"object"},"AdminlessReminder_LTkyMDUxNTk5":{"properties":{"deletion_scheduled_for":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["deletion_scheduled_for"],"type":"object"},"EventVia_Mjc4MzcyNzE0":{"enum":["scim","user"],"type":"string"},"Event_LTMwMTMyODM5":{"properties":{"conversation":{"$ref":"#/components/schemas/UUID"},"data":{"description":"The action of changing the permission to add members to a channel","example":"ZXhhbXBsZQo=","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"$ref":"#/components/schemas/AccessRoleLegacy_LTYwOTAxMDI1"},"access_role_v2":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"add_type":{"$ref":"#/components/schemas/JoinType_LTY4MDg2MzA5"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"code":{"$ref":"#/components/schemas/ASCII"},"conversation_role":{"$ref":"#/components/schemas/RoleName"},"creator":{"$ref":"#/components/schemas/UUID"},"data":{"description":"Extra (symmetric) data (i.e. ciphertext, Base64 in JSON) that is common with all other recipients.","type":"string"},"deletion_scheduled_for":{"$ref":"#/components/schemas/UTCTimeMillis"},"depth":{"$ref":"#/components/schemas/HistoryDuration"},"email":{"type":"string"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/EpochTimestamp"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"has_password":{"description":"Whether the conversation has a password","type":"boolean"},"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"key":{"$ref":"#/components/schemas/ASCII"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message":{"type":"string"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"new_group_id":{"$ref":"#/components/schemas/GroupId"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_recipient":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"qualified_target":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"qualified_user_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"reason":{"$ref":"#/components/schemas/EdMemberLeftReason_OTAyMDA4NzEw"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"recipient":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"sender":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"status":{"$ref":"#/components/schemas/TypingStatus_LTg5MzcyNDMy"},"target":{"$ref":"#/components/schemas/UUID"},"team":{"$ref":"#/components/schemas/UUID"},"text":{"description":"The ciphertext for the recipient (Base64 in JSON)","type":"string"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"},"uri":{"$ref":"#/components/schemas/HttpsUrl"},"user_ids":{"deprecated":true,"description":"Deprecated, use qualified_user_ids","items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"users":{"items":{"$ref":"#/components/schemas/SimpleMember_NTY5MTcxMzcx"},"type":"array"}},"required":["users","add_type","reason","qualified_user_ids","user_ids","qualified_target","name","access","key","code","uri","has_password","qualified_id","type","members","group_id","epoch","epoch_timestamp","cipher_suite","qualified_recipient","receipt_mode","sender","recipient","text","status","add_permission","depth","deletion_scheduled_for"],"type":"object"},"from":{"$ref":"#/components/schemas/UUID"},"qualified_conversation":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_from":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"subconv":{"type":"string"},"team":{"$ref":"#/components/schemas/UUID"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"},"type":{"$ref":"#/components/schemas/EventType_LTQ3NTQyNDYz"},"via":{"$ref":"#/components/schemas/EventVia_Mjc4MzcyNzE0"}},"required":["type","data","qualified_conversation","qualified_from","via","time"],"type":"object"},"AddBot_NjI0ODkyODk3":{"properties":{"locale":{"$ref":"#/components/schemas/Locale"},"provider":{"$ref":"#/components/schemas/UUID"},"service":{"$ref":"#/components/schemas/UUID"}},"required":["provider","service"],"type":"object"},"RemoveBotResponse_LTUxNTQ4MDEy":{"properties":{"event":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"required":["event"],"type":"object"},"UpdateBotPrekeys_LTg3NzYxODg0":{"properties":{"prekeys":{"items":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"type":"array"}},"required":["prekeys"],"type":"object"},"UserClients":{"additionalProperties":{"items":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"type":"array"},"description":"Map of user id to list of client ids.","example":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":["60f85e4b15ad3786","6e323ab31554353b"]},"type":"object"},"BotUserView_LTE2MTkwMTcw":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"handle":{"$ref":"#/components/schemas/Handle"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"team":{"$ref":"#/components/schemas/UUID"}},"required":["id","name","accent_id"],"type":"object"},"NewServiceResponse_LTExMzcwMjg5":{"properties":{"auth_token":{"$ref":"#/components/schemas/ASCII"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["id"],"type":"object"},"NewService_LTYwOTU1MDQ3":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"auth_token":{"$ref":"#/components/schemas/ASCII"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"description":{"maxLength":1024,"minLength":1,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"public_key":{"$ref":"#/components/schemas/ServiceKeyPEM"},"summary":{"maxLength":128,"minLength":1,"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"maxItems":3,"minItems":1,"type":"array"}},"required":["name","summary","description","base_url","public_key","assets","tags"],"type":"object"},"ServiceKeyPEM":{"example":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu+Kg/PHHU3atXrUbKnw0\nG06FliXcNt3lMwl2os5twEDcPPFw/feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPH\nWvUBdiLfGrZqJO223DB6D8K2Su/odmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKV\nVPOaOzgtAB21XKRiQ4ermqgi3/njr03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiS\nbUKr/BeArYRcjzr/h5m1In6fG/if9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg8\n7X883H+LA/d6X5CTiPv1VMxXdBUiGPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7la\nnQIDAQAB\n-----END PUBLIC KEY-----\n","type":"string"},"ServiceTag_LTMyNTEzNjYy":{"enum":["audio","books","business","design","education","entertainment","finance","fitness","food-drink","games","graphics","health","integration","lifestyle","media","medical","movies","music","news","photography","poll","productivity","quiz","rating","shopping","social","sports","travel","tutorial","video","weather"],"type":"string"},"Service_MjcyOTA5NjQx":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"auth_tokens":{"items":{"$ref":"#/components/schemas/ASCII"},"minItems":1,"type":"array"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"description":{"type":"string"},"enabled":{"type":"boolean"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"public_keys":{"items":{"$ref":"#/components/schemas/ServiceKey_NzY5NTY5NzYy"},"minItems":1,"type":"array"},"summary":{"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"type":"array"}},"required":["id","name","summary","description","base_url","auth_tokens","public_keys","assets","tags","enabled"],"type":"object"},"ServiceKeyType_NTEzNzI4NTA2":{"enum":["rsa"],"type":"string"},"ServiceKey_NzY5NTY5NzYy":{"properties":{"pem":{"$ref":"#/components/schemas/ServiceKeyPEM"},"size":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"type":{"$ref":"#/components/schemas/ServiceKeyType_NTEzNzI4NTA2"}},"required":["type","size","pem"],"type":"object"},"UpdateService_MjAxNzQ2Njkz":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"description":{"maxLength":1024,"minLength":1,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"summary":{"maxLength":128,"minLength":1,"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"maxItems":3,"minItems":1,"type":"array"}},"type":"object"},"UpdateServiceConn_LTQ1OTYwNjIz":{"properties":{"auth_tokens":{"items":{"$ref":"#/components/schemas/ASCII"},"maxItems":2,"minItems":1,"type":"array"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"enabled":{"type":"boolean"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"public_keys":{"items":{"$ref":"#/components/schemas/ServiceKeyPEM"},"maxItems":2,"minItems":1,"type":"array"}},"required":["password"],"type":"object"},"DeleteService_LTY2NzY5NzMz":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["password"],"type":"object"},"ServiceProfile_LTc2MDQzNTk3":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"description":{"type":"string"},"enabled":{"type":"boolean"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"provider":{"$ref":"#/components/schemas/UUID"},"summary":{"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"type":"array"}},"required":["id","provider","name","summary","description","assets","tags","enabled"],"type":"object"},"ServiceProfilePage_Njg1NDQ5Njc4":{"properties":{"has_more":{"type":"boolean"},"services":{"items":{"$ref":"#/components/schemas/ServiceProfile_LTc2MDQzNTk3"},"type":"array"}},"required":["has_more","services"],"type":"object"},"ServiceTagList":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"type":"array"},"UpdateServiceWhitelist_LTU5MDAwMTIw":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"provider":{"$ref":"#/components/schemas/UUID"},"whitelisted":{"type":"boolean"}},"required":["provider","id","whitelisted"],"type":"object"},"NewProviderResponse_OTE0ODI2NjU0":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"password":{"maxLength":1024,"minLength":8,"type":"string"}},"required":["id"],"type":"object"},"NewProvider_LTEyMTY5MjYy":{"properties":{"description":{"maxLength":1024,"minLength":1,"type":"string"},"email":{"$ref":"#/components/schemas/Email"},"name":{"maxLength":128,"minLength":1,"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["name","email","url","description"],"type":"object"},"ProviderActivationResponse_LTgzNTU3MzA5":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"ProviderLogin_LTE2MTk2NTM5":{"properties":{"email":{"$ref":"#/components/schemas/Email"},"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["email","password"],"type":"object"},"PasswordReset_LTYzNDYxNTQ3":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"CompletePasswordReset_LTYzMDAxNDA1":{"properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"},"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["key","code","password"],"type":"object"},"DeleteProvider_MzYxMzM3Mjg2":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["password"],"type":"object"},"UpdateProvider_LTQwMjY4MDgy":{"properties":{"description":{"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"url":{"$ref":"#/components/schemas/HttpsUrl"}},"type":"object"},"EmailUpdate_LTYwODE0ODQ5":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"PasswordChange_NDI0ODgwNDU0":{"properties":{"new_password":{"maxLength":1024,"minLength":6,"type":"string"},"old_password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["old_password","new_password"],"type":"object"},"Provider_NDIyMzQ3ODIy":{"properties":{"description":{"type":"string"},"email":{"$ref":"#/components/schemas/Email"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["id","name","email","url","description"],"type":"object"},"DomainRedirectConfig_NTI5NDE5MDQy":{"properties":{"backend":{"$ref":"#/components/schemas/HttpsUrl_HttpsUrl_NjUyMDgzNzk3"},"domain_redirect":{"$ref":"#/components/schemas/DomainRedirectConfigTag_MjE2MDI4MDIw"}},"required":["domain_redirect","backend"],"type":"object"},"DomainRedirectConfigTag_MjE2MDI4MDIw":{"enum":["remove","backend","no-registration"],"type":"string"},"HttpsUrl_HttpsUrl_NjUyMDgzNzk3":{"properties":{"config_url":{"$ref":"#/components/schemas/HttpsUrl"},"webapp_url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["config_url","webapp_url"],"type":"object"},"DomainRedirectResponse_V10_LTEyMjI4NTM0":{"properties":{"backend":{"$ref":"#/components/schemas/HttpsUrl_Maybe_HttpsUrl_LTQ1MDkyMzY2"},"domain_redirect":{"$ref":"#/components/schemas/DomainRedirectTag_LTY3NjU1MDEy"},"due_to_existing_account":{"type":"boolean"},"sso_code":{"$ref":"#/components/schemas/UUID"}},"required":["domain_redirect","sso_code","backend"],"type":"object"},"DomainRedirectTag_LTY3NjU1MDEy":{"enum":["none","locked","sso","backend","no-registration","pre-authorized"],"type":"string"},"HttpsUrl_Maybe_HttpsUrl_LTQ1MDkyMzY2":{"properties":{"config_url":{"$ref":"#/components/schemas/HttpsUrl"},"webapp_url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["config_url"],"type":"object"},"GetDomainRegistrationRequest_LTg4NTM1MzM2":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"DomainOwnershipToken_NTU0ODc1NDE5":{"properties":{"domain_ownership_token":{"$ref":"#/components/schemas/Token"}},"required":["domain_ownership_token"],"type":"object"},"Base64URLByteString":{"example":"ZXhhbXBsZQo=","type":"string"},"Token":{"example":"ZXhhbXBsZQo=","type":"string"},"ChallengeToken_Mzk3NTcwOTM3":{"properties":{"challenge_token":{"$ref":"#/components/schemas/Token"}},"required":["challenge_token"],"type":"object"},"TeamInviteConfig_MTg4Nzk4NzMz":{"properties":{"domain_redirect":{"$ref":"#/components/schemas/TeamDomainRedirectTag_MjQwMjc1Mjk3"},"sso":{"example":"99db9768-04e3-4b5d-9268-831b6a25c4ab","format":"uuid","type":"string"},"team":{"$ref":"#/components/schemas/UUID"},"team_invite":{"$ref":"#/components/schemas/TeamInviteTag_LTQyNTMyNzA0"}},"required":["team_invite","team"],"type":"object"},"TeamInviteTag_LTQyNTMyNzA0":{"enum":["allowed","not-allowed","team"],"type":"string"},"TeamDomainRedirectTag_MjQwMjc1Mjk3":{"enum":["no-registration","none"],"type":"string"},"RegisteredDomains_V10_NDYwNzYyMTMy":{"properties":{"registered_domains":{"items":{"$ref":"#/components/schemas/DomainRegistrationResponse_V10_MjE0NDkxODY4"},"type":"array"}},"required":["registered_domains"],"type":"object"},"DomainRegistrationResponse_V10_MjE0NDkxODY4":{"properties":{"authorized_team":{"$ref":"#/components/schemas/UUID"},"backend":{"$ref":"#/components/schemas/HttpsUrl_Maybe_HttpsUrl_LTQ1MDkyMzY2"},"dns_verification_token":{"$ref":"#/components/schemas/ASCII"},"domain":{"$ref":"#/components/schemas/Domain"},"domain_redirect":{"$ref":"#/components/schemas/DomainRedirectTag_LTY3NjU1MDEy"},"sso_code":{"$ref":"#/components/schemas/UUID"},"team":{"$ref":"#/components/schemas/UUID"},"team_invite":{"$ref":"#/components/schemas/TeamInviteTag_LTQyNTMyNzA0"}},"required":["domain","domain_redirect","sso_code","backend","team_invite","team"],"type":"object"},"DomainVerificationChallenge_NjIwMzA1MjE5":{"properties":{"dns_verification_token":{"$ref":"#/components/schemas/ASCII"},"id":{"$ref":"#/components/schemas/UUID"},"token":{"$ref":"#/components/schemas/Token"}},"required":["id","token","dns_verification_token"],"type":"object"},"UserGroup_Identity_NTg4MTY1MjEx":{"properties":{"channels":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"},"channelsCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"createdAt":{"$ref":"#/components/schemas/UTCTimeMillis"},"id":{"$ref":"#/components/schemas/UUID"},"managedBy":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"membersCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["id","name","members","managedBy","createdAt"],"type":"object"},"NewUserGroup_MzYxODU0OTU1":{"properties":{"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["name","members"],"type":"object"},"UserGroupPage_UserGroup_Const_LTMxNDg5MDAy":{"description":"This is the last page if it contains fewer rows than requested. There may be 0 rows on a page.","properties":{"page":{"items":{"$ref":"#/components/schemas/UserGroup_Const_NTMzOTAzMzA1"},"type":"array"},"total":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["page","total"],"type":"object"},"UserGroup_Const_NTMzOTAzMzA1":{"properties":{"channels":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"},"channelsCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"createdAt":{"$ref":"#/components/schemas/UTCTimeMillis"},"id":{"$ref":"#/components/schemas/UUID"},"managedBy":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"membersCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["id","name","managedBy","createdAt"],"type":"object"},"UserGroupUpdate_MjUyNTA3Mjgy":{"properties":{"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["name"],"type":"object"},"UserGroupAddUsers_LTgzOTYzNzk0":{"properties":{"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["members"],"type":"object"},"UpdateUserGroupMembers_LTg1MzQ2NDY3":{"properties":{"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["members"],"type":"object"},"UpdateUserGroupChannels_LTIyMjcwMTMx":{"properties":{"channels":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["channels"],"type":"object"},"UserGroupNameAvailability_LTYzMDE1NTk4":{"properties":{"name_available":{"type":"boolean"}},"required":["name_available"],"type":"object"},"CheckUserGroupName_LTg0ODU1OTk1":{"properties":{"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["name"],"type":"object"},"CreatedApp_LTM3NjUxOTY1":{"properties":{"cookie":{"$ref":"#/components/schemas/SomeUserToken"},"user":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}},"required":["user","cookie"],"type":"object"},"SomeUserToken":{"type":"string"},"NewApp_LTQwODMwMzQ4":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"category":{"description":"Category name (if uncertain, pick \"other\")","type":"string"},"description":{"maxLength":300,"minLength":0,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["name","category","description","password"],"type":"object"},"PutApp_LTE4MDc1OTM4":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"category":{"description":"Category name (if uncertain, pick \"other\")","type":"string"},"description":{"maxLength":300,"minLength":0,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"}},"type":"object"},"RefreshAppCookieResponse_LTQ0MjU1NTIw":{"properties":{"cookie":{"$ref":"#/components/schemas/SomeUserToken"}},"required":["cookie"],"type":"object"},"RefreshAppCookieRequest_MjEyMDMyMTk5":{"properties":{"password":{"description":"The password of the authenticated admin for verification. or if the user has only SAML credentials.","maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"Conversation_GroupConvType_MzQzMTQ1OTg3":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/ConvMembers_LTc2MDg1NDg2"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"},"ConvMembers_LTc2MDg1NDg2":{"description":"Users of a conversation","properties":{"others":{"description":"All other current users of this conversation","items":{"$ref":"#/components/schemas/OtherMember_LTgzNzE2MTk4"},"type":"array"},"self":{"$ref":"#/components/schemas/Member_OTA5OTgyNzcw"}},"required":["others"],"type":"object"},"ConversationRolesList":{"properties":{"conversation_roles":{"items":{"$ref":"#/components/schemas/ConversationRole"},"type":"array"}},"required":["conversation_roles"],"type":"object"},"ConversationRole":{"properties":{"actions":{"description":"The set of actions allowed for this role","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"conversation_role":{"$ref":"#/components/schemas/RoleName"}}},"Action":{"enum":["add_conversation_member","remove_conversation_member","modify_conversation_name","modify_conversation_message_timer","modify_conversation_receipt_mode","modify_conversation_access","modify_other_conversation_member","leave_conversation","delete_conversation","modify_add_permission"],"type":"string"},"GroupInfoData":{"description":"This object can only be parsed in TLS format. Please refer to the MLS specification for details."},"MultiTabePg_ConvrsIdqf_cLORm_Q_Mzg0MjYzNzM0":{"properties":{"has_more":{"type":"boolean"},"paging_state":{"$ref":"#/components/schemas/ConversationIds_PagingState"},"qualified_conversations":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"}},"required":["qualified_conversations","has_more","paging_state"],"type":"object"},"ConversationIds_PagingState":{"type":"string"},"GetMuliTabPgRqs_ConvrIdLcOm10_ODU0NjQ1NDMz":{"description":"A request to list some or all of a user's ConversationIds, including remote ones","properties":{"paging_state":{"$ref":"#/components/schemas/ConversationIds_PagingState"},"size":{"description":"optional, must be <= 1000, defaults to 1000.","format":"int32","maximum":1000,"minimum":1,"type":"integer"}},"type":"object"},"ConversationsResponse_GroupConvType_ODkxMjM2ODM0":{"description":"Response object for getting metadata of a list of conversations","properties":{"failed":{"description":"The server failed to fetch these conversations, most likely due to network issues while contacting a remote server","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"},"found":{"items":{"$ref":"#/components/schemas/OwnConversation_GroupConvType_LTU2MzYxNTg0"},"type":"array"},"not_found":{"description":"These conversations either don't exist or are deleted.","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"}},"required":["found","not_found","failed"],"type":"object"},"OwnConversation_GroupConvType_LTU2MzYxNTg0":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"},"ListConversations_MjkxMTIwODMz":{"description":"A request to list some of a user's conversations, including remote ones. Maximum 1000 qualified conversation IDs","properties":{"qualified_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"maxItems":1000,"minItems":1,"type":"array"}},"required":["qualified_ids"],"type":"object"},"ConversationCoverView_LTMwNDkxMTA1":{"description":"Limited view of Conversation.","properties":{"has_password":{"type":"boolean"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"}},"required":["id","has_password"],"type":"object"},"CreateGroupConversation_GroupConvType_MTQ3OTc4NDk3":{"description":"A created group-conversation object extended with a list of failed-to-add users","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"failed_to_add":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/ConvMembers_LTc2MDg1NDg2"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch","failed_to_add"],"type":"object"},"NewConv_LTgzNTk1NDQx":{"description":"JSON object to create a new conversation. When using 'qualified_users' (preferred), you can omit 'users'","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells":{"type":"boolean"},"conversation_role":{"$ref":"#/components/schemas/RoleName"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"history":{"$ref":"#/components/schemas/History"},"message_timer":{"description":"Per-conversation message timer","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"maxLength":256,"minLength":1,"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"qualified_users":{"description":"List of qualified user IDs (excluding the requestor) to be part of this conversation","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"skip_creator":{"description":"Don't add creator to the conversation, only works for team admins not wanting to be part of the channels they create.","type":"boolean"},"team":{"$ref":"#/components/schemas/ConvTeamInfo_Mzc5NjcyNjAz"},"users":{"deprecated":true,"description":"List of user IDs (excluding the requestor) to be part of this conversation (deprecated)","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"type":"object"},"ConvTeamInfo_Mzc5NjcyNjAz":{"description":"Team information of this conversation","properties":{"managed":{"description":"This field MUST NOT be used by clients. It is here only for backwards compatibility of the interface."},"teamid":{"$ref":"#/components/schemas/UUID"}},"required":["teamid","managed"],"type":"object"},"v6_OwnConversation_GroupConvType_LTU2MzYxNTg0V6":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"},"v9_OwnConversation_GroupConvType_LTU2MzYxNTg0":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"},"PublicSubConversation_MjI2NTIxMzU4":{"description":"An MLS subconversation","properties":{"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_id":{"$ref":"#/components/schemas/GroupId"},"members":{"items":{"$ref":"#/components/schemas/ClientIdentity_MjAxMjI3NTUw"},"type":"array"},"parent_qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"subconv_id":{"type":"string"}},"required":["parent_qualified_id","subconv_id","group_id","epoch","members"],"type":"object"},"ClientIdentity_MjAxMjI3NTUw":{"properties":{"client_id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"domain":{"$ref":"#/components/schemas/Domain"},"user_id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","user_id","client_id"],"type":"object"},"MLSReset_NzgwODA3ODc4":{"properties":{"epoch":{"format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"group_id":{"$ref":"#/components/schemas/GroupId"}},"required":["group_id","epoch"],"type":"object"},"v3_OwnConversation_GroupConvType_LTU2MzYxNTg0V3":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/EpochTimestamp"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch","epoch_timestamp","cipher_suite"],"type":"object"},"NewOne2OneConv_LTI3OTc4NDAz":{"description":"JSON object to create a new 1:1 conversation. When using 'qualified_users' (preferred), you can omit 'users'","properties":{"name":{"maxLength":256,"minLength":1,"type":"string"},"qualified_users":{"description":"List of qualified user IDs (excluding the requestor) to be part of this conversation","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"team":{"$ref":"#/components/schemas/ConvTeamInfo_Mzc5NjcyNjAz"},"users":{"deprecated":true,"description":"List of user IDs (excluding the requestor) to be part of this conversation (deprecated)","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"type":"object"},"MLSOne2OneConversation_SomeKey_GroupConvType_LTg2MTcwMjY3":{"properties":{"conversation":{"$ref":"#/components/schemas/v9_OwnConversation_GroupConvType_LTU2MzYxNTg0"},"public_keys":{"$ref":"#/components/schemas/MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx"}},"required":["conversation","public_keys"],"type":"object"},"SomeKey":{},"MLSKeys_SomeKey_LTUzNDA5MzA3":{"properties":{"ecdsa_secp256r1_sha256":{"$ref":"#/components/schemas/SomeKey"},"ecdsa_secp384r1_sha384":{"$ref":"#/components/schemas/SomeKey"},"ecdsa_secp521r1_sha512":{"$ref":"#/components/schemas/SomeKey"},"ed25519":{"$ref":"#/components/schemas/SomeKey"}},"required":["ed25519","ecdsa_secp256r1_sha256","ecdsa_secp384r1_sha384","ecdsa_secp521r1_sha512"],"type":"object"},"MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx":{"properties":{"removal":{"$ref":"#/components/schemas/MLSKeys_SomeKey_LTUzNDA5MzA3"}},"required":["removal"],"type":"object"},"InviteQualified_ODYyODIyNjYz":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"qualified_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"minItems":1,"type":"array"}},"required":["qualified_users"],"type":"object"},"JoinConversationByCode_NjgzMzM4Mjg5":{"description":"Request body for joining a conversation by code","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"},"password":{"maxLength":1024,"minLength":8,"type":"string"}},"required":["key","code"],"type":"object"},"ConversationCode_Mjg3OTI1NTMx":{"description":"Contains conversation properties to update","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"}},"required":["key","code"],"type":"object"},"CreateConversationCodeRequest_NTYzMTA1NDYz":{"description":"Request body for creating a conversation code","properties":{"password":{"description":"Password for accessing the conversation via guest link. Set to null or omit for no password.","maxLength":1024,"minLength":8,"type":"string"}},"type":"object"},"LockableFeature_GuestLinksConfig_LTcwNjU0NDMw":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"FeatureStatus_LTMzMTUwODEw":{"enum":["enabled","disabled"],"type":"string"},"LockStatus_LTIyMTU5OTkw":{"enum":["locked","unlocked"],"type":"string"},"OtherMemberUpdate_LTM1MjYzOTU0":{"description":"Update user properties of other members relative to a conversation","properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"}},"type":"object"},"ConversationAccessData_MjMxMTI5ODc3":{"properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"}},"required":["access","access_role"],"type":"object"},"ConversationHistoryUpdate_LTg5MDQ5Nzgx":{"properties":{"history":{"$ref":"#/components/schemas/History"}},"required":["history"],"type":"object"},"MemberUpdate_LTg4NTQ0OTYz":{"properties":{"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"type":"object"},"TeamConversationList_OTI3MzY3NzY0":{"description":"Team conversation list","properties":{"conversations":{"items":{"$ref":"#/components/schemas/TeamConversation_LTIwNzgyNTEz"},"type":"array"}},"required":["conversations"],"type":"object"},"TeamConversation_LTIwNzgyNTEz":{"description":"Team conversation data","properties":{"conversation":{"$ref":"#/components/schemas/UUID"},"managed":{"description":"This field MUST NOT be used by clients. It is here only for backwards compatibility of the interface."}},"required":["conversation","managed"],"type":"object"},"ClientMismatch_ODUyODM0MDQ0":{"properties":{"deleted":{"$ref":"#/components/schemas/UserClients"},"missing":{"$ref":"#/components/schemas/UserClients"},"redundant":{"$ref":"#/components/schemas/UserClients"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["time","missing","redundant","deleted"],"type":"object"},"NewOtrMessage_LTUyMTE5MTMw":{"properties":{"data":{"type":"string"},"native_priority":{"$ref":"#/components/schemas/Priority_ODA3NDM3MDYy"},"native_push":{"type":"boolean"},"recipients":{"$ref":"#/components/schemas/UserClientMap"},"report_missing":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"sender":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"transient":{"type":"boolean"}},"required":["sender","recipients"],"type":"object"},"UserClientMap":{"additionalProperties":{"additionalProperties":{"type":"string"},"type":"object"},"type":"object"},"Priority_ODA3NDM3MDYy":{"enum":["low","high"],"type":"string"},"MessageSendingStatus_ODg0NDgyNDk4":{"description":"The Proteus message sending status. It has these fields:\n- `time`: Time of sending message.\n- `missing`: Clients that the message /should/ have been encrypted for, but wasn't.\n- `redundant`: Clients that the message /should not/ have been encrypted for, but was.\n- `deleted`: Clients that were deleted.\n- `failed_to_send`: When message sending fails for some clients but succeeds for others, e.g., because a remote backend is unreachable, this field will contain the list of clients for which the message sending failed. This list should be empty when message sending is not even tried, like when some clients are missing.","properties":{"deleted":{"$ref":"#/components/schemas/QualifiedUserClients"},"failed_to_confirm_clients":{"$ref":"#/components/schemas/QualifiedUserClients"},"failed_to_send":{"$ref":"#/components/schemas/QualifiedUserClients"},"missing":{"$ref":"#/components/schemas/QualifiedUserClients"},"redundant":{"$ref":"#/components/schemas/QualifiedUserClients"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["time","missing","redundant","deleted","failed_to_send","failed_to_confirm_clients"],"type":"object"},"QualifiedNewOtrMessage":{"description":"This object can only be parsed from Protobuf.\nThe specification for the protobuf types is here: \nhttps://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto."},"BotConvView_LTYzMjIzMjQz":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"members":{"items":{"$ref":"#/components/schemas/OtherMember_LTgzNzE2MTk4"},"type":"array"},"name":{"type":"string"}},"required":["id","members"],"type":"object"},"TeamUpdateData_LTE0NTM2NTU5":{"properties":{"icon":{"$ref":"#/components/schemas/Icon"},"icon_key":{"maxLength":256,"minLength":1,"type":"string"},"name":{"maxLength":256,"minLength":1,"type":"string"},"splash_screen":{"$ref":"#/components/schemas/Icon"}},"type":"object"},"Team_NDg4MjQwOTIw":{"description":"`binding` is deprecated, and should be ignored. The non-binding teams API is not used (and will not be supported from API version V4 onwards), and `binding` will always be `true`.","properties":{"binding":{"$ref":"#/components/schemas/TeamBinding_LTE4NTM5MTc0"},"creator":{"$ref":"#/components/schemas/UUID"},"icon":{"$ref":"#/components/schemas/Icon"},"icon_key":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"splash_screen":{"$ref":"#/components/schemas/Icon"}},"required":["id","creator","name","icon"],"type":"object"},"TeamBinding_LTE4NTM5MTc0":{"deprecated":true,"description":"Deprecated, please ignore.","enum":[true,false],"type":"boolean"},"TeamDeleteData_ODI5NTU0ODE5":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"type":"object"},"ConversationPage_LTIwMDU2NDI3":{"description":"This is the last page if it contains fewer rows than requested. There may be 0 rows on a page.","properties":{"page":{"items":{"$ref":"#/components/schemas/ConversationSearchResult_NDI0MTcyMDU3"},"type":"array"}},"required":["page"],"type":"object"},"ConversationSearchResult_NDI0MTcyMDU3":{"properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"admin_count":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"id":{"$ref":"#/components/schemas/UUID"},"member_count":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"}},"required":["id","access","member_count","admin_count"],"type":"object"},"LockableFeature_SSOConfig_NjcyMjU4MDY2":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_LegalholdConfig_LTc5MTk5OTIw":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_LegalholdConfig_NjM3MTkxNjYw":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_SearchVisibilityAvailableConfig_LTMzNTkxODI1":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"TeamSearchVisibilityView_Mzg3MzMzMTk3":{"description":"Search visibility value for the team","properties":{"search_visibility":{"$ref":"#/components/schemas/TeamSearchVisibility_LTIzODE2Njk3"}},"required":["search_visibility"],"type":"object"},"TeamSearchVisibility_LTIzODE2Njk3":{"description":"value of visibility","enum":["standard","no-name-outside-team"],"type":"string"},"LockableFtur_RqiExnmVfCg_NjUyMzgzNzY5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_DigitalSignaturesConfig_ODM2MDA2NDU4":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5":{"properties":{"config":{"$ref":"#/components/schemas/AppLockConfigB_Covered_Identity_NDIxOTc2Njkz"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"AppLockConfigB_Covered_Identity_NDIxOTc2Njkz":{"properties":{"enforceAppLock":{"type":"boolean"},"inactivityTimeoutSecs":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["enforceAppLock","inactivityTimeoutSecs"],"type":"object"},"Feature_AppLockConfigB_Bare_Identity_MTAzMjI5NDYy":{"properties":{"config":{"$ref":"#/components/schemas/AppLockConfigB_Covered_Identity_NDIxOTc2Njkz"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"LockableFeature_FileSharingConfig_MjgwNjIzODEz":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_FileSharingConfig_LTUyNjkxMzM4":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFeature_ClassifiedDomainsConfig_LTY1ODQwODg1":{"properties":{"config":{"$ref":"#/components/schemas/ClassifiedDomainsConfig_LTg4MDcwMDg2"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"ClassifiedDomainsConfig_LTg4MDcwMDg2":{"properties":{"domains":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["domains"],"type":"object"},"LockableFtur_CnfigBIdy_NzY1NDU5MDAy":{"properties":{"config":{"$ref":"#/components/schemas/ConferenceCallingConfigB_Covered_Identity_NDMwMjcyMzM1"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"ConferenceCallingConfigB_Covered_Identity_NDMwMjcyMzM1":{"properties":{"useSFTForOneToOneCalls":{"type":"boolean"}},"type":"object"},"Feature_ConferenceCallingConfigB_Bare_Identity_Njc2NTcxNTI3":{"properties":{"config":{"$ref":"#/components/schemas/ConferenceCallingConfigB_Covered_Identity_NDMwMjcyMzM1"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2":{"properties":{"config":{"$ref":"#/components/schemas/SelfDeletingMessagesConfigB_Covered_Identity_LTY0NzQ4MzU1"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"SelfDeletingMessagesConfigB_Covered_Identity_LTY0NzQ4MzU1":{"properties":{"enforcedTimeoutSeconds":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["enforcedTimeoutSeconds"],"type":"object"},"Featur_SlfDingMsCoBIdy_LTg2MzYzNjc2":{"properties":{"config":{"$ref":"#/components/schemas/SelfDeletingMessagesConfigB_Covered_Identity_LTY0NzQ4MzU1"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_GuestLinksConfig_NjQyMDMxNjg3":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_SndFactorPasswordChallengeConfig_NDc0MzUyMzQz":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw":{"properties":{"config":{"$ref":"#/components/schemas/MLSConfigB_Covered_Identity_LTEzNTk3MzM5"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"MLSConfigB_Covered_Identity_LTEzNTk3MzM5":{"description":"allowlist of users that may change protocols","properties":{"allowedCipherSuites":{"items":{"$ref":"#/components/schemas/CipherSuiteTag"},"type":"array"},"defaultCipherSuite":{"$ref":"#/components/schemas/CipherSuiteTag"},"defaultProtocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"groupInfoDiagnostics":{"type":"boolean"},"protocolToggleUsers":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"supportedProtocols":{"items":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"type":"array"}},"required":["protocolToggleUsers","defaultProtocol","allowedCipherSuites","defaultCipherSuite","supportedProtocols"],"type":"object"},"Feature_MLSConfigB_Bare_Identity_LTI5MjA3MDYy":{"properties":{"config":{"$ref":"#/components/schemas/MLSConfigB_Covered_Identity_LTEzNTk3MzM5"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_ExposeInvitationURLsToTeamAdminConfig_LTY5NzY2Mzg5":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_SearchVisibilityInboundConfig_MTI1NzQxODY2":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_OutlookCalIntegrationConfig_LTg0MjIxNTMx":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4":{"properties":{"config":{"$ref":"#/components/schemas/MlsE2EIdConfigB_Covered_Identity_NDQ0NjEwNzA3"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"MlsE2EIdConfigB_Covered_Identity_NDQ0NjEwNzA3":{"description":"When a client first tries to fetch or renew a certificate, they may need to login to an identity provider (IdP) depending on their IdP domain authentication policy. The user may have a grace period during which they can \"snooze\" this login. The duration of this grace period (in seconds) is set in the `verificationDuration` parameter, which is enforced separately by each client. After the grace period has expired, the client will not allow the user to use the application until they have logged to refresh the certificate. The default value is 1 day (86400s). The client enrolls using the Automatic Certificate Management Environment (ACME) protocol. The `acmeDiscoveryUrl` parameter must be set to the HTTPS URL of the ACME server discovery endpoint for this team. It is of the form \"https://acme.{backendDomain}/acme/{provisionerName}/discovery\". For example: `https://acme.example.com/acme/provisioner1/discovery`.","properties":{"acmeDiscoveryUrl":{"$ref":"#/components/schemas/HttpsUrl"},"crlProxy":{"$ref":"#/components/schemas/HttpsUrl"},"useProxyOnMobile":{"type":"boolean"},"verificationExpiration":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["verificationExpiration"],"type":"object"},"Feature_MlsE2EIdConfigB_Bare_Identity_LTUxODYzODEx":{"properties":{"config":{"$ref":"#/components/schemas/MlsE2EIdConfigB_Covered_Identity_NDQ0NjEwNzA3"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"LockableFtur_MsignCfBIdy_LTE1NjAxNjU2":{"properties":{"config":{"$ref":"#/components/schemas/MlsMigrationConfigB_Covered_Identity_LTM3NDQ4Mzg4"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"MlsMigrationConfigB_Covered_Identity_LTM3NDQ4Mzg4":{"properties":{"allowManualMigration":{"type":"boolean"},"finaliseRegardlessAfter":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"},"startTime":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"}},"type":"object"},"Feature_MlsMigrationConfigB_Bare_Identity_LTQyMDAxMTkz":{"properties":{"config":{"$ref":"#/components/schemas/MlsMigrationConfigB_Covered_Identity_LTM3NDQ4Mzg4"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4":{"properties":{"config":{"$ref":"#/components/schemas/EnforceFilDwadLtCgB_vIy_LTQ2NjU1Nzcx"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"EnforceFilDwadLtCgB_vIy_LTQ2NjU1Nzcx":{"properties":{"enforcedDownloadLocation":{"type":"string"}},"type":"object"},"Featur_EnfocilDwdLCgBIy_MjcxMzc1NDA5":{"properties":{"config":{"$ref":"#/components/schemas/EnforceFilDwadLtCgB_vIy_LTQ2NjU1Nzcx"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"LockableFeature_LimitedEventFanoutConfig_MTg3ODM0NzU0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"NP_LockableFtur_ghdCnfiSOVsyAvIRqExmDpB_MGwUT2Q_LTM1Mzc0ODgy":{"properties":{"allowedGlobalOperations":{"$ref":"#/components/schemas/LockableFeature_AllowedGlobalOperationsConfig_NjQ1MjA5MDYw"},"appLock":{"$ref":"#/components/schemas/LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5"},"apps":{"$ref":"#/components/schemas/LockableFeature_AppsConfig_MzQyNTMxNTk5"},"assetAuditLog":{"$ref":"#/components/schemas/LockableFeature_AssetAuditLogConfig_NDQ3MzcyMzk2"},"backgroundEffects":{"$ref":"#/components/schemas/LockableFeature_BackgroundEffectsConfig_MTg1MTk3NTM5"},"cells":{"$ref":"#/components/schemas/LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3"},"cellsInternal":{"$ref":"#/components/schemas/LockableFtur_CsInfigBdy_LTQ3MTU3Mjg0"},"channels":{"$ref":"#/components/schemas/LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw"},"chatBubbles":{"$ref":"#/components/schemas/LockableFeature_ChatBubblesConfig_NDgwMTQzNjI2"},"classifiedDomains":{"$ref":"#/components/schemas/LockableFeature_ClassifiedDomainsConfig_LTY1ODQwODg1"},"conferenceCalling":{"$ref":"#/components/schemas/LockableFtur_CnfigBIdy_NzY1NDU5MDAy"},"consumableNotifications":{"$ref":"#/components/schemas/LockableFeature_ConsumableNotificationsConfig_MjUxMjczMjM0"},"conversationGuestLinks":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"},"digitalSignatures":{"$ref":"#/components/schemas/LockableFeature_DigitalSignaturesConfig_ODM2MDA2NDU4"},"domainRegistration":{"$ref":"#/components/schemas/LockableFeature_DomainRegistrationConfig_NzU0NjczNTE0"},"enforceFileDownloadLocation":{"$ref":"#/components/schemas/LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4"},"exposeInvitationURLsToTeamAdmin":{"$ref":"#/components/schemas/LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1"},"fileSharing":{"$ref":"#/components/schemas/LockableFeature_FileSharingConfig_MjgwNjIzODEz"},"legalhold":{"$ref":"#/components/schemas/LockableFeature_LegalholdConfig_LTc5MTk5OTIw"},"limitedEventFanout":{"$ref":"#/components/schemas/LockableFeature_LimitedEventFanoutConfig_MTg3ODM0NzU0"},"meetings":{"$ref":"#/components/schemas/LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw"},"meetingsPremium":{"$ref":"#/components/schemas/LockableFeature_MeetingsPremiumConfig_NDg1ODEyNTA1"},"mls":{"$ref":"#/components/schemas/LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw"},"mlsE2EId":{"$ref":"#/components/schemas/LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4"},"mlsMigration":{"$ref":"#/components/schemas/LockableFtur_MsignCfBIdy_LTE1NjAxNjU2"},"outlookCalIntegration":{"$ref":"#/components/schemas/LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0"},"preventAdminlessGroups":{"$ref":"#/components/schemas/LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw"},"searchVisibility":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5"},"searchVisibilityInbound":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy"},"selfDeletingMessages":{"$ref":"#/components/schemas/LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2"},"simplifiedUserConnectionRequestQRCode":{"$ref":"#/components/schemas/LockableFtur_SimpfdUsCnRqQg_Njk5NjU4OTgy"},"sndFactorPasswordChallenge":{"$ref":"#/components/schemas/LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx"},"sso":{"$ref":"#/components/schemas/LockableFeature_SSOConfig_NjcyMjU4MDY2"},"stealthUsers":{"$ref":"#/components/schemas/LockableFeature_StealthUsersConfig_LTE1MTk2NzIz"},"validateSAMLemails":{"$ref":"#/components/schemas/LockableFtur_RqiExnmVfCg_NjUyMzgzNzY5"}},"required":["legalhold","sso","searchVisibility","searchVisibilityInbound","validateSAMLemails","digitalSignatures","appLock","fileSharing","classifiedDomains","conferenceCalling","selfDeletingMessages","conversationGuestLinks","sndFactorPasswordChallenge","mls","exposeInvitationURLsToTeamAdmin","outlookCalIntegration","mlsE2EId","mlsMigration","enforceFileDownloadLocation","limitedEventFanout","domainRegistration","channels","preventAdminlessGroups","cells","allowedGlobalOperations","consumableNotifications","chatBubbles","apps","simplifiedUserConnectionRequestQRCode","assetAuditLog","stealthUsers","cellsInternal","meetings","meetingsPremium","backgroundEffects"],"type":"object"},"LockableFeature_DomainRegistrationConfig_NzU0NjczNTE0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"ChannelPermissions_Mzc1MTM3NTg2":{"enum":["team-members","everyone","admins"],"type":"string"},"ChannelsConfigB_Covered_Identity_ODk2MTk3NDQ4":{"properties":{"allowed_to_create_channels":{"$ref":"#/components/schemas/ChannelPermissions_Mzc1MTM3NTg2"},"allowed_to_open_channels":{"$ref":"#/components/schemas/ChannelPermissions_Mzc1MTM3NTg2"}},"required":["allowed_to_create_channels","allowed_to_open_channels"],"type":"object"},"LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw":{"properties":{"config":{"$ref":"#/components/schemas/ChannelsConfigB_Covered_Identity_ODk2MTk3NDQ4"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"PreventAdminlessGroupsPromotionStrategy_Mjc1ODA2MDU1":{"enum":["alphabetical","random","all"],"type":"string"},"PreventAdminlessGroupsConfigB_Covered_Identity_MjQ2NDYwMTk2":{"properties":{"deletionTimeout":{"maximum":18446744073709551615,"minimum":0,"type":"integer"},"deletionTimeoutDuration":{"type":"string"},"promotionStrategy":{"$ref":"#/components/schemas/PreventAdminlessGroupsPromotionStrategy_Mjc1ODA2MDU1"},"reminderTimeoutDurations":{"items":{"type":"string"},"type":"array"},"reminderTimeouts":{"items":{"maximum":18446744073709551615,"minimum":0,"type":"integer"},"type":"array"}},"required":["promotionStrategy"],"type":"object"},"LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw":{"properties":{"config":{"$ref":"#/components/schemas/PreventAdminlessGroupsConfigB_Covered_Identity_MjQ2NDYwMTk2"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"CellsPropertyStatus_MTQ5NjE2MzQ4":{"enum":["enabled","disabled","enforced"],"type":"string"},"CellsProperty_NzcxMDIzMzk0":{"properties":{"default":{"$ref":"#/components/schemas/CellsPropertyStatus_MTQ5NjE2MzQ4"},"enabled":{"type":"boolean"}},"required":["enabled","default"],"type":"object"},"CellsUsers_LTQ4NTEyODA1":{"properties":{"externals":{"type":"boolean"},"guests":{"type":"boolean"}},"required":["externals","guests"],"type":"object"},"CellsCollaboraStatus_MTgzNTQyNzUz":{"properties":{"enabled":{"type":"boolean"}},"required":["enabled"],"type":"object"},"CellsPublicLinks_MjgxMzQ3Mzk4":{"properties":{"enableFiles":{"type":"boolean"},"enableFolders":{"type":"boolean"},"enforceExpirationDefault":{"format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"enforceExpirationMax":{"format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"enforcePassword":{"type":"boolean"}},"required":["enableFiles","enableFolders","enforcePassword","enforceExpirationMax","enforceExpirationDefault"],"type":"object"},"CellsRecycle_LTQxMTg3NTkx":{"properties":{"allowSkip":{"type":"boolean"},"autoPurgeDays":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"disable":{"type":"boolean"}},"required":["autoPurgeDays","disable","allowSkip"],"type":"object"},"CellsConfigStorage_LTM0NDMwODM4":{"properties":{"perFileQuotaBytes":{"type":"string"},"recycle":{"$ref":"#/components/schemas/CellsRecycle_LTQxMTg3NTkx"}},"required":["perFileQuotaBytes","recycle"],"type":"object"},"CellsUserMetaTags_LTc4Njk4NTY0":{"properties":{"allowFreeValues":{"type":"boolean"},"defaultValues":{"items":{"type":"string"},"type":"array"}},"required":["defaultValues","allowFreeValues"],"type":"object"},"CellsNamespaces_MzUxMjEzOTQw":{"properties":{"usermetaTags":{"$ref":"#/components/schemas/CellsUserMetaTags_LTc4Njk4NTY0"}},"required":["usermetaTags"],"type":"object"},"CellsMetadata_LTY1OTM5MTM0":{"properties":{"namespaces":{"$ref":"#/components/schemas/CellsNamespaces_MzUxMjEzOTQw"}},"required":["namespaces"],"type":"object"},"CellsConfigB_Covered_Identity_LTE1NzkwOTcz":{"example":{"channels":{"default":"enabled","enabled":true},"collabora":{"enabled":false},"groups":{"default":"enabled","enabled":true},"metadata":{"namespaces":{"usermetaTags":{"allowFreeValues":true,"defaultValues":[]}}},"one2one":{"default":"enabled","enabled":true},"publicLinks":{"enableFiles":true,"enableFolders":true,"enforceExpirationDefault":0,"enforceExpirationMax":0,"enforcePassword":false},"storage":{"perFileQuotaBytes":"100000000","recycle":{"allowSkip":false,"autoPurgeDays":30,"disable":false}},"users":{"externals":true,"guests":false}},"properties":{"channels":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"collabora":{"$ref":"#/components/schemas/CellsCollaboraStatus_MTgzNTQyNzUz"},"groups":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"metadata":{"$ref":"#/components/schemas/CellsMetadata_LTY1OTM5MTM0"},"one2one":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"publicLinks":{"$ref":"#/components/schemas/CellsPublicLinks_MjgxMzQ3Mzk4"},"storage":{"$ref":"#/components/schemas/CellsConfigStorage_LTM0NDMwODM4"},"users":{"$ref":"#/components/schemas/CellsUsers_LTQ4NTEyODA1"}},"required":["channels","groups","one2one","users","collabora","publicLinks","storage","metadata"],"type":"object"},"LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3":{"properties":{"config":{"$ref":"#/components/schemas/CellsConfigB_Covered_Identity_LTE1NzkwOTcz"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"AllowedGlobalOperationsConfig_MzAwOTU1MDkx":{"properties":{"mlsConversationReset":{"type":"boolean"}},"required":["mlsConversationReset"],"type":"object"},"LockableFeature_AllowedGlobalOperationsConfig_NjQ1MjA5MDYw":{"properties":{"config":{"$ref":"#/components/schemas/AllowedGlobalOperationsConfig_MzAwOTU1MDkx"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_ConsumableNotificationsConfig_MjUxMjczMjM0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_ChatBubblesConfig_NDgwMTQzNjI2":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_AppsConfig_MzQyNTMxNTk5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFtur_SimpfdUsCnRqQg_Njk5NjU4OTgy":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_AssetAuditLogConfig_NDQ3MzcyMzk2":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_StealthUsersConfig_LTE1MTk2NzIz":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"CellsBackend_LTE1Nzg3NzQ2":{"properties":{"url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["url"],"type":"object"},"CollaboraEdition_LTg2NDA1NDQ4":{"enum":["NO","CODE","COOL"],"type":"string"},"CellsCollabora_LTMzNDA5MDIz":{"properties":{"edition":{"$ref":"#/components/schemas/CollaboraEdition_LTg2NDA1NDQ4"}},"required":["edition"],"type":"object"},"CellsStorage_LTY2Mzc5NzY1":{"properties":{"perUserQuotaBytes":{"example":"-1","type":"string"},"totalLimitBytes":{"example":"-1","type":"string"}},"required":["perUserQuotaBytes"],"type":"object"},"CellsInternalConfigB_Covered_Identity_LTUzMDkwOTAz":{"properties":{"backend":{"$ref":"#/components/schemas/CellsBackend_LTE1Nzg3NzQ2"},"collabora":{"$ref":"#/components/schemas/CellsCollabora_LTMzNDA5MDIz"},"storage":{"$ref":"#/components/schemas/CellsStorage_LTY2Mzc5NzY1"}},"required":["backend","collabora","storage"],"type":"object"},"LockableFtur_CsInfigBdy_LTQ3MTU3Mjg0":{"properties":{"config":{"$ref":"#/components/schemas/CellsInternalConfigB_Covered_Identity_LTUzMDkwOTAz"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_MeetingsPremiumConfig_NDg1ODEyNTA1":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_BackgroundEffectsConfig_MTg1MTk3NTM5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_ChannelsConfigB_Bare_Identity_LTU4NTE4MTgx":{"properties":{"config":{"$ref":"#/components/schemas/ChannelsConfigB_Covered_Identity_ODk2MTk3NDQ4"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Featur_Vsiond_17PvAmlGpCfgBIy_LTIzMjQ4MDk1V17":{"properties":{"config":{"$ref":"#/components/schemas/Versiond_17_PvtAmlGupCfgBaIy_LTY1MTkyNDcw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Versiond_17_PvtAmlGupCfgBaIy_LTY1MTkyNDcw":{"properties":{"deletionTimeoutDuration":{"type":"string"},"promotionStrategy":{"$ref":"#/components/schemas/PreventAdminlessGroupsPromotionStrategy_Mjc1ODA2MDU1"},"reminderTimeoutDurations":{"items":{"type":"string"},"type":"array"}},"required":["promotionStrategy","deletionTimeoutDuration","reminderTimeoutDurations"],"type":"object"},"Feature_CellsConfigB_Bare_Identity_NzU2NjgxNzEw":{"properties":{"config":{"$ref":"#/components/schemas/CellsConfigB_Covered_Identity_LTE1NzkwOTcz"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_MeetingsConfig_NDc2MzM0MDE1":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"MLSMessageSendingStatus_NjA1NDA0MTE4":{"properties":{"events":{"description":"A list of events caused by sending the message.","items":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"},"type":"array"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["events","time"],"type":"object"},"MLSMessage":{"description":"This object can only be parsed in TLS format. Please refer to the MLS specification for details."},"CommitBundle":{"description":"This object can only be parsed in TLS format. Please refer to the MLS specification for details."},"MeetingWithConversation_LTMyNzA4NzU0":{"description":"A scheduled meeting with its associated conversation","properties":{"conversation":{"$ref":"#/components/schemas/Conversation_GroupConvType_MzQzMTQ1OTg3"},"created_at":{"$ref":"#/components/schemas/UTCTime"},"end_time":{"$ref":"#/components/schemas/UTCTime"},"invited_emails":{"items":{"$ref":"#/components/schemas/Email"},"type":"array"},"qualified_conversation":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_creator":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Meeting_MjgxMDUyNjc3"},"recurrence":{"$ref":"#/components/schemas/Recurrence_LTQ0OTc0ODE2"},"start_time":{"$ref":"#/components/schemas/UTCTime"},"title":{"maxLength":256,"minLength":1,"type":"string"},"tzid":{"$ref":"#/components/schemas/TimeZone"},"updated_at":{"$ref":"#/components/schemas/UTCTime"}},"required":["qualified_id","title","qualified_creator","start_time","end_time","tzid","qualified_conversation","invited_emails","created_at","updated_at","conversation"],"type":"object"},"Qualified_Id_IdTag_Meeting_MjgxMDUyNjc3":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","id"],"type":"object"},"TimeZone":{"type":"string"},"Frequency_Mzk0ODQwOTM3":{"enum":["daily","weekly","monthly","yearly"],"type":"string"},"Recurrence_LTQ0OTc0ODE2":{"description":"Recurrence pattern for meetings","properties":{"frequency":{"$ref":"#/components/schemas/Frequency_Mzk0ODQwOTM3"},"interval":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"until":{"$ref":"#/components/schemas/UTCTime"}},"required":["frequency"],"type":"object"},"NewMeeting_LTI1NTMzOTU5":{"description":"Request to create a new meeting","properties":{"end_time":{"$ref":"#/components/schemas/UTCTime"},"invited_emails":{"items":{"$ref":"#/components/schemas/Email"},"type":"array"},"recurrence":{"$ref":"#/components/schemas/Recurrence_LTQ0OTc0ODE2"},"start_time":{"$ref":"#/components/schemas/UTCTime"},"title":{"maxLength":256,"minLength":1,"type":"string"},"tzid":{"$ref":"#/components/schemas/TimeZone"}},"required":["start_time","end_time","tzid","title"],"type":"object"},"UpdateMeeting_NTExNzYxMTcz":{"description":"Request to update a meeting","properties":{"end_time":{"$ref":"#/components/schemas/UTCTime"},"recurrence":{"$ref":"#/components/schemas/Recurrence_LTQ0OTc0ODE2"},"start_time":{"$ref":"#/components/schemas/UTCTime"},"title":{"maxLength":256,"minLength":1,"type":"string"}},"type":"object"},"Meeting_ODU0OTMzMTgw":{"description":"A scheduled meeting","properties":{"created_at":{"$ref":"#/components/schemas/UTCTime"},"end_time":{"$ref":"#/components/schemas/UTCTime"},"invited_emails":{"items":{"$ref":"#/components/schemas/Email"},"type":"array"},"qualified_conversation":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_creator":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Meeting_MjgxMDUyNjc3"},"recurrence":{"$ref":"#/components/schemas/Recurrence_LTQ0OTc0ODE2"},"start_time":{"$ref":"#/components/schemas/UTCTime"},"title":{"maxLength":256,"minLength":1,"type":"string"},"tzid":{"$ref":"#/components/schemas/TimeZone"},"updated_at":{"$ref":"#/components/schemas/UTCTime"}},"required":["qualified_id","title","qualified_creator","start_time","end_time","tzid","qualified_conversation","invited_emails","created_at","updated_at"],"type":"object"},"MeetingEmailsInvitation_NzgyNzUzMzcz":{"description":"Emails invitation","properties":{"emails":{"items":{"$ref":"#/components/schemas/Email"},"type":"array"}},"required":["emails"],"type":"object"},"CustomBackend_LTQxODI0MjQ0":{"description":"Description of a custom backend","properties":{"config_json_url":{"$ref":"#/components/schemas/HttpsUrl"},"webapp_welcome_url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["config_json_url","webapp_welcome_url"],"type":"object"},"ViewLegalHoldService_LTE3MzQzNDkw":{"properties":{"settings":{"$ref":"#/components/schemas/ViewLegalHoldServiceInfo_LTc3NjI2MzQ3"},"status":{"$ref":"#/components/schemas/LHServiceStatus_ODc3NzE0Mjg3"}},"required":["status"],"type":"object"},"LHServiceStatus_ODc3NzE0Mjg3":{"enum":["configured","not_configured","disabled"],"type":"string"},"Fingerprint":{"example":"ioy3GeIjgQRsobf2EKGO3O8mq/FofFxHRqy0T4ERIZ8=","type":"string"},"ViewLegalHoldServiceInfo_LTc3NjI2MzQ3":{"properties":{"auth_token":{"$ref":"#/components/schemas/ASCII"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"fingerprint":{"$ref":"#/components/schemas/Fingerprint"},"public_key":{"$ref":"#/components/schemas/ServiceKeyPEM"},"team_id":{"$ref":"#/components/schemas/UUID"}},"required":["team_id","base_url","fingerprint","auth_token","public_key"],"type":"object"},"NewLegalHoldService_Mzg0ODQ5NDU1":{"properties":{"auth_token":{"$ref":"#/components/schemas/ASCII"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"public_key":{"$ref":"#/components/schemas/ServiceKeyPEM"}},"required":["base_url","public_key","auth_token"],"type":"object"},"RemoveLegalHoldSettingsRequest_NzMzMzMyMDAz":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"UserLegalHoldStatusResponse_LTQ1MzUxMTE3":{"properties":{"client":{"$ref":"#/components/schemas/IdObject_ClientId_LTM3NjQyODM5"},"last_prekey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"status":{"$ref":"#/components/schemas/UserLegalHoldStatus_LTQ2ODA2NTU5"}},"required":["status"],"type":"object"},"IdObject_ClientId_LTM3NjQyODM5":{"properties":{"id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"}},"required":["id"],"type":"object"},"DisableLegalHoldForUserRequest_LTYyMDYxOTEy":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"ApproveLegalHoldForUserRequest_NjEyNzYyMTIx":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"TeamMembersPage_NzYwNDIxODgx":{"properties":{"hasMore":{"type":"boolean"},"members":{"items":{"$ref":"#/components/schemas/TeamMember_Optional_NTU0MDcyNzI1"},"type":"array"},"pagingState":{"$ref":"#/components/schemas/TeamMembers_PagingState"}},"required":["members","hasMore","pagingState"],"type":"object"},"Permissions_NDE0ODM5NDUx":{"description":"This is just a complicated way of representing a team role. self and copy always have to contain the same integer, and only the following integers are allowed: 1025 (partner), 1587 (member), 5951 (admin), 8191 (owner). Unit tests of the galley-types package in wire-server contain an authoritative list.","properties":{"copy":{"description":"Permissions that this user is able to grant others","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"self":{"description":"Permissions that the user has","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["self","copy"],"type":"object"},"TeamMember_Optional_NTU0MDcyNzI1":{"description":"team member data","properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"legalhold_status":{"$ref":"#/components/schemas/UserLegalHoldStatus_LTQ2ODA2NTU5"},"permissions":{"$ref":"#/components/schemas/Permissions_NDE0ODM5NDUx"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user"],"type":"object"},"TeamMembers_PagingState":{"type":"string"},"TeamMemberList_Optional_LTM1ODE2MzM0":{"description":"list of team member","properties":{"hasMore":{"$ref":"#/components/schemas/ListType_LTkyMDM4MzA1"},"members":{"description":"the array of team members","items":{"$ref":"#/components/schemas/TeamMember_Optional_NTU0MDcyNzI1"},"type":"array"}},"required":["members","hasMore"],"type":"object"},"ListType_LTkyMDM4MzA1":{"description":"true if 'members' doesn't contain all team members","enum":[true,false],"type":"boolean"},"UserIdList_MzA1MTI1Njgx":{"properties":{"user_ids":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["user_ids"],"type":"object"},"TeamMemberDeleteData_LTg2OTEyOTI4":{"description":"Data for a team member deletion request in case of binding teams.","properties":{"password":{"description":"The account password to authorise the deletion.","maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"NewTeamMember_Required_LTg2NjU5OTI2":{"description":"Required data when creating new team members","properties":{"member":{"description":"the team member to add (the legalhold_status field must be null or missing!)","properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"permissions":{"$ref":"#/components/schemas/Permissions_NDE0ODM5NDUx"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","permissions"],"type":"object"}},"required":["member"],"type":"object"},"NewTeamCollaborator_LTIxNjEzMTYw":{"properties":{"permissions":{"items":{"$ref":"#/components/schemas/CollaboratorPermission_NDg5NTg2ODgy"},"type":"array"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","permissions"],"type":"object"},"CollaboratorPermission_NDg5NTg2ODgy":{"description":"

Permission granted to a team collaborator.

  • `create_team_conversation`: equivalent to the `CreateConversation` and `AddRemoveConvMember` permissions for team members (both implied in the `member` role); allows creating team group conversations and adding members to them.
  • \n
  • `implicit_connection`: team members are implicitly connected to each other, allowing conversations (1:1 or group) without an explicit connection request. This permission grants the same to a collaborator.
\n

NB: a member of team A can always open conversations with a collaborator of team A; the permission only controls the collaborator's abilities.

","enum":["create_team_conversation","implicit_connection"],"type":"string"},"TeamCollaborator_LTI3MzM1MTYz":{"properties":{"permissions":{"items":{"$ref":"#/components/schemas/CollaboratorPermission_NDg5NTg2ODgy"},"type":"array"},"team":{"$ref":"#/components/schemas/UUID"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","team","permissions"],"type":"object"},"QueuedNotificationList_MTU0ODEyNTQ2":{"description":"Zero or more notifications","properties":{"has_more":{"description":"Whether there are still more notifications.","type":"boolean"},"notifications":{"description":"Notifications","items":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"},"type":"array"},"time":{"$ref":"#/components/schemas/UTCTime"}},"required":["notifications"],"type":"object"},"Object":{"additionalProperties":true,"description":"A single notification event","properties":{"type":{"description":"Event type","type":"string"}},"title":"Event","type":"object"},"QueuedNotification_NTY2NzY2MTU2":{"description":"A single notification","properties":{"id":{"$ref":"#/components/schemas/UUID"},"payload":{"description":"List of events","items":{"$ref":"#/components/schemas/Object"},"minItems":1,"type":"array"}},"required":["id","payload"],"type":"object"},"FormRedirect":{"properties":{"uri":{"type":"string"},"xml":{"$ref":"#/components/schemas/AuthnRequest"}},"type":"object"},"AuthnRequest":{"properties":{"iD":{"$ref":"#/components/schemas/Id_AuthnRequest"},"issueInstant":{"$ref":"#/components/schemas/Time"},"issuer":{"$ref":"#/components/schemas/URI"},"nameIDPolicy":{"$ref":"#/components/schemas/NameIdPolicy"}},"required":["iD","issueInstant","issuer"],"type":"object"},"Id_AuthnRequest":{"properties":{"iD":{"type":"string"}},"required":["iD"],"type":"object"},"Time":{"properties":{"time":{"$ref":"#/components/schemas/UTCTime"}},"required":["time"],"type":"object"},"URI":{"type":"string"},"NameIdPolicy":{"properties":{"allowCreate":{"type":"boolean"},"format":{"$ref":"#/components/schemas/NameIDFormat"},"spNameQualifier":{"type":"string"}},"required":["format","allowCreate"],"type":"object"},"NameIDFormat":{"enum":["NameIDFUnspecified","NameIDFEmail","NameIDFX509","NameIDFWindows","NameIDFKerberos","NameIDFEntity","NameIDFPersistent","NameIDFTransient"],"type":"string"},"SsoSettings":{"properties":{"default_sso_code":{"$ref":"#/components/schemas/URI"}},"type":"object"},"GetByEmailResp_LTMxNTY3MjA0":{"properties":{"sso_code":{"$ref":"#/components/schemas/UUID"}},"type":"object"},"GetByEmailReq_LTY4MzE3Njgy":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"IdPConfig_WireIdP_NDA5MTE4Mjk0":{"properties":{"extraInfo":{"$ref":"#/components/schemas/WireIdP_ODMzOTExMzYw"},"id":{"$ref":"#/components/schemas/URI"},"metadata":{"$ref":"#/components/schemas/IdPMetadata_MTI3NzE4MTA0"}},"required":["id","metadata","extraInfo"],"type":"object"},"SignedCertificate":{"type":"string"},"IdPMetadata_MTI3NzE4MTA0":{"properties":{"certAuthnResponse":{"items":{"$ref":"#/components/schemas/SignedCertificate"},"minItems":1,"type":"array"},"issuer":{"$ref":"#/components/schemas/URI"},"requestURI":{"type":"string"}},"required":["issuer","requestURI","certAuthnResponse"],"type":"object"},"WireIdPAPIVersion_NTEyMzIwNTU3":{"enum":["WireIdPAPIV1","WireIdPAPIV2"],"type":"string"},"WireIdP_ODMzOTExMzYw":{"properties":{"apiVersion":{"enum":["WireIdPAPIV1","WireIdPAPIV2"],"type":"string"},"domain":{"example":"example.com","type":"string"},"handle":{"type":"string"},"oldIssuers":{"items":{"$ref":"#/components/schemas/URI"},"type":"array"},"replacedBy":{"type":"string"},"team":{"$ref":"#/components/schemas/UUID"}},"required":["team","apiVersion","oldIssuers","replacedBy","handle","domain"],"type":"object"},"IdPList":{"properties":{"providers":{"items":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"},"type":"array"}},"required":["providers"],"type":"object"},"IdPMetadataInfo":{"maxProperties":1,"minProperties":1,"properties":{"value":{"type":"string"}},"type":"object"},"CreateScimTokenResponse_LTIzOTU2NDU4":{"properties":{"info":{"$ref":"#/components/schemas/ScimTokenInfo_LTI5NjgwNzA1"},"token":{"type":"string"}},"required":["token","info"],"type":"object"},"ScimTokenInfo_LTI5NjgwNzA1":{"properties":{"created_at":{"$ref":"#/components/schemas/UTCTime"},"description":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"idp":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"team":{"$ref":"#/components/schemas/UUID"}},"required":["team","id","created_at","description","name"],"type":"object"},"CreateScimToken_OTY0NjYxMDQ2":{"properties":{"description":{"type":"string"},"idp":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"required":["description"],"type":"object"},"ScimTokenName_LTgzOTM2OTI4":{"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"},"ScimTokenList_NjQwNTYxOTAw":{"properties":{"tokens":{"items":{"$ref":"#/components/schemas/ScimTokenInfo_LTI5NjgwNzA1"},"type":"array"}},"required":["tokens"],"type":"object"},"Asset_Qualified_AssetKey_MzU1MjMxNTA5":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"expires":{"$ref":"#/components/schemas/UTCTimeMillis"},"key":{"$ref":"#/components/schemas/AssetKey"},"token":{"$ref":"#/components/schemas/ASCII"}},"required":["key","domain"],"type":"object"},"AssetSource":{},"NewAssetToken_NTAwMDQwODYy":{"properties":{"token":{"$ref":"#/components/schemas/ASCII"}},"required":["token"],"type":"object"},"PushToken_ODYzMDYzOTA4":{"description":"Native Push Token","properties":{"app":{"description":"Application","type":"string"},"client":{"description":"Client ID","type":"string"},"token":{"description":"Access Token","type":"string"},"transport":{"$ref":"#/components/schemas/Transport_NDk2NzU5NDIy"}},"required":["transport","app","token","client"],"type":"object"},"Transport_NDk2NzU5NDIy":{"description":"Transport","enum":["GCM","APNS","APNS_SANDBOX","APNS_VOIP","APNS_VOIP_SANDBOX"],"type":"string"},"PushTokenList_NDI0Mjc3MzY3":{"description":"List of Native Push Tokens","properties":{"tokens":{"description":"Push tokens","items":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"},"type":"array"}},"required":["tokens"],"type":"object"},"ServerTime_LTM4NTI3MzIx":{"description":"The current server time","properties":{"time":{"$ref":"#/components/schemas/UTCTime"}},"required":["time"],"type":"object"}},"securitySchemes":{"ZAuth":{"description":"Must be a token retrieved by calling 'POST /login' or 'POST /access'. It must be presented in this format: 'Bearer \\'.","in":"header","name":"Authorization","type":"apiKey"}}},"security":[{"ZAuth":[]}],"openapi":"3.0.0"} \ No newline at end of file diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 80fa4d5fb64..dd5471760b1 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -236,10 +236,11 @@ internalEndpointsSwaggerDocsAPIs = -- -- Dual to `internalEndpointsSwaggerDocsAPI`. versionedSwaggerDocsAPI :: Servant.Server VersionedSwaggerDocsAPI -versionedSwaggerDocsAPI (Just (VersionNumber V17)) = +versionedSwaggerDocsAPI (Just (VersionNumber V18)) = swaggerSchemaUIServer $ devVersionSwagger & S.info . S.description ?~ $((unTypeCode . embedText) =<< makeRelativeToProject "docs/swagger.md") +versionedSwaggerDocsAPI (Just (VersionNumber V17)) = swaggerPregenUIServer $(pregenSwagger V17) versionedSwaggerDocsAPI (Just (VersionNumber V16)) = swaggerPregenUIServer $(pregenSwagger V16) versionedSwaggerDocsAPI (Just (VersionNumber V15)) = swaggerPregenUIServer $(pregenSwagger V15) versionedSwaggerDocsAPI (Just (VersionNumber V14)) = swaggerPregenUIServer $(pregenSwagger V14) diff --git a/services/galley/src/Galley/API/Public/Feature.hs b/services/galley/src/Galley/API/Public/Feature.hs index 580e65a48a3..1b029a63144 100644 --- a/services/galley/src/Galley/API/Public/Feature.hs +++ b/services/galley/src/Galley/API/Public/Feature.hs @@ -71,7 +71,7 @@ featureAPI = <@> featureAPIGetPut <@> mkNamedAPI @'("get", PreventAdminlessGroupsConfig) getFeature <@> mkNamedAPI @"put-PreventAdminlessGroupsConfig@v16" setFeature - <@> mkNamedAPI @"put-PreventAdminlessGroupsConfig@v17" setFeature + <@> mkNamedAPI @"put-PreventAdminlessGroupsConfig@v18" setFeature <@> mkNamedAPI @'("get", CellsConfig) getFeature <@> mkNamedAPI @"put-CellsConfig@v13" setFeature <@> mkNamedAPI @'("put", CellsConfig) setFeature From 853bb1f6e1a196c01b3d2184e198e85d48006972 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 21 Aug 2026 15:07:34 +0200 Subject: [PATCH 103/113] [WPB-28050] New oauth scopes for meetings for calendar integration. (part 1) (#5462) * [drive-by] Better error rendering in oauth scopes test. * [drive-by] Implement FromByteString OAuthScope using ToByteString. (This way we only have to change one instance in the FUTURE for update to the type.) * Fix: support for implied oauth scopes in unit test. (This will change again in https://wearezeta.atlassian.net/browse/WPB-28193) --- ...r-meetings-for-google-calendar-integration | 1 + charts/nginz/values.yaml | 15 +++++++- libs/wire-api/src/Wire/API/OAuth.hs | 28 +++++++++++---- .../API/Routes/Public/Galley/Conversation.hs | 1 + .../Wire/API/Routes/Public/Galley/Meetings.hs | 2 ++ .../unit/Test/Wire/API/Routes/OAuthScopes.hs | 34 +++++++++++-------- 6 files changed, 59 insertions(+), 22 deletions(-) create mode 100644 changelog.d/1-api-changes/WPB-28050-new-oauth-scopes-for-meetings-for-google-calendar-integration diff --git a/changelog.d/1-api-changes/WPB-28050-new-oauth-scopes-for-meetings-for-google-calendar-integration b/changelog.d/1-api-changes/WPB-28050-new-oauth-scopes-for-meetings-for-google-calendar-integration new file mode 100644 index 00000000000..fcef459a645 --- /dev/null +++ b/changelog.d/1-api-changes/WPB-28050-new-oauth-scopes-for-meetings-for-google-calendar-integration @@ -0,0 +1 @@ +New oauth scopes for meetings for calendar integration. diff --git a/charts/nginz/values.yaml b/charts/nginz/values.yaml index 0f081d64870..d114b3c7d35 100644 --- a/charts/nginz/values.yaml +++ b/charts/nginz/values.yaml @@ -640,6 +640,10 @@ nginx_conf: - path: /conversations/([^/]*)/([^/]*)/add-permission envs: - all + - path: /conversations/([^/]*)/([^/]*)/name + envs: + - all + oauth_scope: conversations_name - path: /broadcast envs: - all @@ -778,7 +782,16 @@ nginx_conf: disable_zauth: true basic_auth: true versioned: false - - path: /meetings(.*) + - path: /meetings$ + envs: + - all + oauth_scope: meetings + ## this rule can't be expressed yet: https://wearezeta.atlassian.net/browse/WPB-28193 + #- path: /meetings/([^/]*)/([^/]*)$ + # envs: + # - all + # oauth_scopes: [write:meetings, admin:meetings] + - path: /meetings/(.*) envs: - all gundeck: diff --git a/libs/wire-api/src/Wire/API/OAuth.hs b/libs/wire-api/src/Wire/API/OAuth.hs index 6ff71d5cacd..9392b7f909e 100644 --- a/libs/wire-api/src/Wire/API/OAuth.hs +++ b/libs/wire-api/src/Wire/API/OAuth.hs @@ -29,6 +29,7 @@ import Data.ByteString.Lazy (fromStrict, toStrict) import Data.HashMap.Strict qualified as HM import Data.Id as Id import Data.Json.Util +import Data.Map qualified as Map import Data.OpenApi (ToParamSchema (..)) import Data.OpenApi qualified as S import Data.Range @@ -199,6 +200,9 @@ data OAuthScope | ReadSelf | WriteConversations | WriteConversationsCode + | WriteConversationsName + | WriteMeetings + | AdminMeetings deriving (Eq, Show, Generic, Ord, Bounded, Enum) deriving (Arbitrary) via (GenericUniform OAuthScope) @@ -217,22 +221,32 @@ instance IsOAuthScope 'ReadSelf where instance IsOAuthScope 'ReadFeatureConfigs where toOAuthScope = ReadFeatureConfigs +instance IsOAuthScope 'WriteConversationsName where + toOAuthScope = WriteConversationsName + +instance IsOAuthScope 'WriteMeetings where + toOAuthScope = WriteMeetings + +instance IsOAuthScope 'AdminMeetings where + toOAuthScope = AdminMeetings + instance ToByteString OAuthScope where builder = \case WriteConversations -> "write:conversations" WriteConversationsCode -> "write:conversations_code" + WriteConversationsName -> "write:conversations_name" + WriteMeetings -> "write:meetings" + AdminMeetings -> "admin:meetings" ReadSelf -> "read:self" ReadFeatureConfigs -> "read:feature_configs" instance FromByteString OAuthScope where parser = do - s <- parser - case T.toLower s of - "write:conversations" -> pure WriteConversations - "write:conversations_code" -> pure WriteConversationsCode - "read:self" -> pure ReadSelf - "read:feature_configs" -> pure ReadFeatureConfigs - _ -> fail "invalid scope" + s <- (toByteString' . T.toLower) <$> parser + let table = Map.fromList [(toByteString' c, c) | c <- [(minBound :: OAuthScope) ..]] + case Map.lookup s table of + Just c -> pure c + Nothing -> fail $ "invalid scope: " <> show s newtype OAuthScopes = OAuthScopes {unOAuthScopes :: Set OAuthScope} deriving (Eq, Show, Generic, Monoid, Semigroup, Arbitrary) diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs index d7ef224b522..68bb60a9d3f 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs @@ -1340,6 +1340,7 @@ type ConversationAPI = :<|> Named "update-conversation-name" ( Summary "Update conversation name" + :> DescriptionOAuthScope 'WriteConversationsName :> CanThrow ('ActionDenied 'ModifyConversationName) :> CanThrow 'ConvNotFound :> CanThrow 'InvalidOperation diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs index 3440cccfa96..db65df71470 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs @@ -23,6 +23,7 @@ import Servant import Wire.API.Error import Wire.API.Error.Galley import Wire.API.Meeting +import Wire.API.OAuth import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named import Wire.API.Routes.Public @@ -49,6 +50,7 @@ type MeetingsAPI = :<|> Named "create-meeting" ( Summary "Create a new meeting" + :> DescriptionOAuthScope 'WriteMeetings :> From 'V17 :> ZLocalUser :> ZConn diff --git a/libs/wire-api/test/unit/Test/Wire/API/Routes/OAuthScopes.hs b/libs/wire-api/test/unit/Test/Wire/API/Routes/OAuthScopes.hs index f75de6f9d07..0400bc3a9a6 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Routes/OAuthScopes.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Routes/OAuthScopes.hs @@ -81,18 +81,26 @@ data Location = Location locScope :: Maybe Text } --- | The scopes that get an OAuth token past nginz to this endpoint. +-- | The scope an OAuth token needs to get past nginz to this endpoint. -- --- Empty when nginz requires no scope, and also when it requires one --- not in 'Wire.API.OAuth.OAuthScopes'. Mistyped scope names are --- caught by 'testScopeNamesAreReal'. +-- libzauth accepts a whole tier range per method, so a token holding +-- @admin:meetings@ may also @POST@. Documenting every accepted scope would be +-- noise, and would force @POST@ to be annotated with both @write:@ and +-- @admin:@; what the docs should name is the /least/ privilege that suffices, +-- so we take the lowest tier that is actually grantable. +-- +-- Empty when nginz requires no scope, and also when no tier it would accept is +-- in 'Wire.API.OAuth.OAuthScope' -- then the endpoint cannot be reached with an +-- OAuth token at all and there is nothing to document. Mistyped scope names +-- are caught by 'testScopeNamesAreReal'. +-- +-- FUTUREWORK(fisx): https://wearezeta.atlassian.net/browse/WPB-28193 enforcedScopes :: Text -> Text -> Set Text enforcedScopes method path = - fromMaybe Set.empty $ do + maybe Set.empty Set.singleton $ do loc <- find (`locationMatches` path) nginzLocations base <- locScope loc - pure . Set.intersection grantableScopes . Set.fromList $ - [tier <> ":" <> base | tier <- methodScopeTiers method] + find (`Set.member` grantableScopes) [tier <> ":" <> base | tier <- methodScopeTiers method] nginzLocations :: [Location] nginzLocations = @@ -161,7 +169,9 @@ pcreOnlyConstructs = ["(?", "\\", "{", "*?", "+?"] -- | @oauth_scope: foo@ in values.yaml names a scope without a tier; libzauth -- decides which tiers satisfy it from the request method. See @verify_scope@ in --- @libs/libzauth/libzauth/src/oauth.rs@ +-- @libs/libzauth/libzauth/src/oauth.rs@. Listed in increasing order of +-- privilege: 'enforcedScopes' takes the first grantable one, so this order +-- decides which scope an endpoint gets documented with. methodScopeTiers :: Text -> [Text] methodScopeTiers = \case "GET" -> ["read", "write", "admin"] @@ -227,13 +237,9 @@ renderFinding f = [ toUrlPiece (fVersion f), fMethod f, fPath f, - renderScopes (fEnforced f), - renderScopes (fDocumented f) + T.pack . show . toList $ fEnforced f, + T.pack . show . toList $ fDocumented f ] - where - renderScopes s - | Set.null s = "-" - | otherwise = T.intercalate " " (Set.toAscList s) findings :: [Finding] findings = From a1fb9bc0a6fdaa8d286f8d0b25cfe6e0572b22ba Mon Sep 17 00:00:00 2001 From: Sven Tennie Date: Mon, 24 Aug 2026 16:45:04 +0200 Subject: [PATCH 104/113] Disable and LOCK preventAdminlessGroups (#5472) The feature has known bugs and should thus not be used in production. This commit unblocks releasing wire-server while letting us quickly resume development on this feature afterwards. --- .../0-release-notes/disable-preventAdminlessGroups.md | 3 +++ charts/wire-server/values.yaml | 3 ++- hack/helm_vars/wire-server/values.yaml.gotmpl | 11 +++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 changelog.d/0-release-notes/disable-preventAdminlessGroups.md diff --git a/changelog.d/0-release-notes/disable-preventAdminlessGroups.md b/changelog.d/0-release-notes/disable-preventAdminlessGroups.md new file mode 100644 index 00000000000..14eed273faf --- /dev/null +++ b/changelog.d/0-release-notes/disable-preventAdminlessGroups.md @@ -0,0 +1,3 @@ +The _prevent adminless groups_ feature has known bugs. Disable and lock it by +Helm configuration for now. Development of this feature will continue, it +should just not be used in production as-is. diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml index 8bea454951f..5bb0b276eb6 100644 --- a/charts/wire-server/values.yaml +++ b/charts/wire-server/values.yaml @@ -260,9 +260,10 @@ galley: allowed_to_open_channels: team-members lockStatus: locked preventAdminlessGroups: + # This feature has known errors. Thus, it must stay disabled for now. defaults: status: disabled - lockStatus: unlocked + lockStatus: locked config: promotionStrategy: alphabetical deletionTimeoutDuration: 7d diff --git a/hack/helm_vars/wire-server/values.yaml.gotmpl b/hack/helm_vars/wire-server/values.yaml.gotmpl index 556e8e1a225..b0309380b56 100644 --- a/hack/helm_vars/wire-server/values.yaml.gotmpl +++ b/hack/helm_vars/wire-server/values.yaml.gotmpl @@ -420,6 +420,17 @@ galley: defaults: status: enabled lockStatus: unlocked + preventAdminlessGroups: + # FUTUREWORK: This feature should be unlocked in the main + # `values.yaml` file. It is just disabled & locked there due to open + # bugs. + defaults: + status: disabled + lockStatus: unlocked + config: + promotionStrategy: alphabetical + deletionTimeoutDuration: 7d + reminderTimeoutDurations: [2d, 4d, 6d] journal: endpoint: http://fake-aws-sqs:4568 queueName: integration-team-events.fifo From 10c32cc681108fcced2201abf895024dadd366a2 Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Tue, 25 Aug 2026 19:29:55 +0200 Subject: [PATCH 105/113] fix: keep hoogle image haddock from hanging in CI (#5474) --- nix/wire-server.nix | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/nix/wire-server.nix b/nix/wire-server.nix index 4d304f2f8c3..d6b914fa5f1 100644 --- a/nix/wire-server.nix +++ b/nix/wire-server.nix @@ -407,7 +407,24 @@ let }; wireServerPackages = (builtins.attrNames (localPackages localModsEnableAll { } { })); - hoogle = (hPkgs localModsOnlyDocs).hoogleWithPackages (p: builtins.map (e: p.${e}) wireServerPackages); + # Haddock for the hoogle image: the image serves the hoogle database and + # haddock HTML (nixpkgs hoogle.nix). The quickjump index is not needed + # for hoogle search: galley's haddock has hung indefinitely in CI right + # before "Documentation created" (task timed out after 1h10m). Haddock + # (and the -O0 compile) are also forced serial: parallel haddock + # (-j$NIX_BUILD_CORES) is the prime deadlock suspect. This only affects + # the hoogle image, not the production docker images (built from + # imagesNoDocs, enableDocs = false). + hoogleHaddockOverrides = hself: hsuper: + builtins.mapAttrs + (_: drv: + hlib.overrideCabal + (hlib.disableParallelBuilding drv) + (old: { doHaddockQuickjump = false; })) + (lib.genAttrs wireServerPackages (name: hsuper.${name})); + + hoogle = ((hPkgs localModsOnlyDocs).extend hoogleHaddockOverrides).hoogleWithPackages + (p: builtins.map (e: p.${e}) wireServerPackages); # More about dockerTools.streamLayeredImage: # https://nixos.org/manual/nixpkgs/unstable/#ssec-pkgs-dockerTools-streamLayeredImage From a8ea5536f591acfd2ede82e7afde06679b6a6af4 Mon Sep 17 00:00:00 2001 From: Akshay Mankar Date: Wed, 26 Aug 2026 09:58:33 +0200 Subject: [PATCH 106/113] NewStoredUser: Remove handle (#5475) The cassandra interpreter was storing the handle without "claiming" it, expecting the caller to make a subsequent call to claim the handle. The postgresql interpreter was claiming it unsafely, so it'd throw a 500 if the handle was already claimed. Removing the handle from this type seems the more correct way where the calling party must make a subsequent call to claim the handle and deal with this partial failure in creating a user. --- changelog.d/3-bug-fixes/reject-duplicate-handles | 4 ++++ .../src/Wire/AppSubsystem/Interpreter.hs | 3 +-- libs/wire-subsystems/src/Wire/StoredUser.hs | 10 ++++------ libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs | 4 ++-- libs/wire-subsystems/src/Wire/UserStore/Postgres.hs | 11 +++-------- services/brig/src/Brig/API/User.hs | 10 +++++----- services/brig/src/Brig/Data/User.hs | 11 +---------- services/brig/src/Brig/Provider/API.hs | 1 - 8 files changed, 20 insertions(+), 34 deletions(-) create mode 100644 changelog.d/3-bug-fixes/reject-duplicate-handles diff --git a/changelog.d/3-bug-fixes/reject-duplicate-handles b/changelog.d/3-bug-fixes/reject-duplicate-handles new file mode 100644 index 00000000000..4b9a627afb9 --- /dev/null +++ b/changelog.d/3-bug-fixes/reject-duplicate-handles @@ -0,0 +1,4 @@ +SCIM: Avoid assigning an already claimed handle to a user. + +Before this if SCIM created a user with a handle already claimed by another user +the handle would get stored for the user even if the overall SCIM call fails. \ No newline at end of file diff --git a/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs index 33cf8fb521c..e1dd13ff30f 100644 --- a/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs @@ -138,7 +138,7 @@ createAppImpl lusr tid newApp = do pure CreatedApp { user = - let usr :: User = newStoredUserToUser (tUntagged (qualifyAs lusr u)) + let usr :: User = newStoredUserToUser (tUntagged (qualifyAs lusr u)) Nothing mbApp :: Maybe AppInfo = Just $ storedAppToAppInfo app lh = UserLegalHoldDisabled -- FUTUREWORK: this needs to be changed as soon as apps can be put under LH. in mkUserProfile EmailVisibleIfOnTeam usr mbApp lh, @@ -276,7 +276,6 @@ appNewStoredUser creator new = do country = loc.lCountry, providerId = Nothing, serviceId = Nothing, - handle = Nothing, expires = Nothing, teamId = creator.teamId, managedBy = defaultManagedBy, diff --git a/libs/wire-subsystems/src/Wire/StoredUser.hs b/libs/wire-subsystems/src/Wire/StoredUser.hs index 414598883f3..d4aa2fc0367 100644 --- a/libs/wire-subsystems/src/Wire/StoredUser.hs +++ b/libs/wire-subsystems/src/Wire/StoredUser.hs @@ -205,7 +205,6 @@ data NewStoredUser = NewStoredUser country :: Maybe Country, providerId :: Maybe ProviderId, serviceId :: Maybe ServiceId, - handle :: Maybe Handle, teamId :: Maybe TeamId, managedBy :: ManagedBy, supportedProtocols :: Set BaseProtocolTag, @@ -238,7 +237,6 @@ deriving instance Maybe Country, Maybe ProviderId, Maybe ServiceId, - Maybe Handle, Maybe TeamId, ManagedBy, Set BaseProtocolTag, @@ -268,7 +266,7 @@ newStoredUserToStoredUser new = country = new.country, providerId = new.providerId, serviceId = new.serviceId, - handle = new.handle, + handle = Nothing, teamId = new.teamId, managedBy = Just new.managedBy, supportedProtocols = Just new.supportedProtocols, @@ -277,8 +275,8 @@ newStoredUserToStoredUser new = -- This saves the identity from `NewStoredUser` even if the user is -- not activated. -newStoredUserToUser :: Qualified NewStoredUser -> User -newStoredUserToUser (Qualified new domain) = +newStoredUserToUser :: Qualified NewStoredUser -> Maybe Handle -> User +newStoredUserToUser (Qualified new domain) mbHandle = User { userQualifiedId = Qualified new.id domain, userType = new.userType, @@ -292,7 +290,7 @@ newStoredUserToUser (Qualified new domain) = userStatus = new.status, userLocale = Locale new.language new.country, userService = newServiceRef <$> new.serviceId <*> new.providerId, - userHandle = new.handle, + userHandle = mbHandle, userExpire = new.expires, userTeam = new.teamId, userManagedBy = new.managedBy, diff --git a/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs index a08179e4e77..6226347fece 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs @@ -443,8 +443,8 @@ insertUser :: PrepQuery W (TupleType NewStoredUser) () insertUser = "INSERT INTO user (id, user_type, name, text_status, picture, assets, email, sso_id, \ \accent_id, password, activated, status, expires, language, \ - \country, provider, service, handle, team, managed_by, supported_protocols, searchable) \ - \VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + \country, provider, service, team, managed_by, supported_protocols, searchable) \ + \VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" insertServiceUser :: PrepQuery W (ProviderId, ServiceId, BotId, ConvId, Maybe TeamId) () insertServiceUser = diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 4b47b76820d..76959781bbb 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -81,8 +81,7 @@ type InsertUserRow = ( UserId, Name, Maybe TextStatus, Pict, Maybe EmailAddress, Maybe UserSSOId, ColourId, Maybe Password, Bool, AccountStatus, Maybe UTCTimeMillis, Language, Maybe Country, Maybe ProviderId, Maybe ServiceId, - Maybe Handle, Maybe TeamId, ManagedBy, Set BaseProtocolTag, Bool, - UserType + Maybe TeamId, ManagedBy, Set BaseProtocolTag, Bool, UserType ) type SelectUserRow = @@ -165,7 +164,6 @@ createUserImpl new mbConv = new.country, new.providerId, new.serviceId, - new.handle, new.teamId, new.managedBy, new.supportedProtocols, @@ -181,14 +179,12 @@ createUserImpl new mbConv = (id, name, text_status, picture, email, sso_id, accent_id, password, activated, account_status, expires, language, country, provider, service, - handle, team, managed_by, supported_protocols, searchable, - user_type) + team, managed_by, supported_protocols, searchable, user_type) VALUES ($1 :: uuid, $2 :: text, $3 :: text?, $4 :: jsonb, $5 :: text?, $6 :: jsonb?, $7 :: integer, $8 :: text?, $9 :: boolean, $10 :: integer, $11 :: timestamptz?, $12 :: text, $13 :: text?, $14 :: uuid?, $15 :: uuid?, - $16 :: text?, $17 :: uuid?, $18 :: integer, $19 :: integer, $20 :: boolean, - $21 :: integer) + $16 :: uuid?, $17 :: integer, $18 :: integer, $19 :: boolean, $20 :: integer) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, text_status = EXCLUDED.text_status, @@ -204,7 +200,6 @@ createUserImpl new mbConv = country = EXCLUDED.country, provider = EXCLUDED.provider, service = EXCLUDED.service, - handle = EXCLUDED.handle, team = EXCLUDED.team, managed_by = EXCLUDED.managed_by, supported_protocols = EXCLUDED.supported_protocols, diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index bbf1d2d86d1..10c4a949c2b 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -239,9 +239,9 @@ createUserSpar new = do tid = newUserSparTeamId new -- Create account - account <- lift $ newStoredUser new' Nothing (Just tid) handle' + account <- lift $ newStoredUser new' Nothing (Just tid) domain <- viewFederationDomain - let u = newStoredUserToUser (Qualified account domain) + let u = newStoredUserToUser (Qualified account domain) handle' lift . liftSem $ do let uid = account.id @@ -488,9 +488,9 @@ createUserWith normalizeScimDisplayName rateLimitKey new = do traverse (liftSem . HashPassword.hashPassword8 rateLimitKey) new'.newUserPassword - newStoredUser new' {newUserPassword = mHashedPassword} mbInv tid mbHandle + newStoredUser new' {newUserPassword = mHashedPassword} mbInv tid domain <- viewFederationDomain - let u = newStoredUserToUser (Qualified account domain) + let u = newStoredUserToUser (Qualified account domain) mbHandle let uid = account.id lift . liftSem $ do Log.debug $ field "user" (toByteString uid) . field "action" (val "User.createUser") @@ -661,7 +661,7 @@ createUserInviteViaScim (NewUserScimInvitation tid uid extId loc name email _) = lift . liftSem $ do UserStore.createUser account Nothing InvitationStore.insertPendingScimUser tid email uid - newStoredUserToUser . Qualified account <$> viewFederationDomain + flip newStoredUserToUser Nothing . Qualified account <$> viewFederationDomain -- | docs/reference/user/registration.md {#RefRestrictRegistration}. checkRestrictedUserCreation :: NewUser password -> ExceptT RegisterError (AppT r) () diff --git a/services/brig/src/Brig/Data/User.hs b/services/brig/src/Brig/Data/User.hs index fe176284211..4163ccd4927 100644 --- a/services/brig/src/Brig/Data/User.hs +++ b/services/brig/src/Brig/Data/User.hs @@ -28,7 +28,6 @@ where import Brig.App import Brig.Options import Control.Error -import Data.Handle (Handle) import Data.Id import Data.Json.Util (toUTCTimeMillis) import Data.Range (fromRange) @@ -43,18 +42,12 @@ import Wire.StoredUser -- | Preconditions: -- -- 1. @newUserUUID u == Just inv || isNothing (newUserUUID u)@. --- 2. If @isJust@, @mbHandle@ must be claimed by user with id @inv@. --- --- Condition (2.) is essential for maintaining handle uniqueness. It is guaranteed by the --- fact that we're setting getting @mbHandle@ from table @"user"@, and when/if it was added --- there, it was claimed properly. newStoredUser :: NewUser Password -> Maybe InvitationId -> Maybe TeamId -> - Maybe Handle -> AppT r NewStoredUser -newStoredUser u inv tid mbHandle = do +newStoredUser u inv tid = do defLoc <- defaultUserLocale <$> asks (.settings) uid <- Id <$> do @@ -103,7 +96,6 @@ newStoredUser u inv tid mbHandle = do country = l.lCountry, providerId = Nothing, serviceId = Nothing, - handle = mbHandle, expires = e, teamId = tid, managedBy = managedBy, @@ -136,7 +128,6 @@ newStoredUserViaScim uid externalId tid locale name email = do country = loc.lCountry, providerId = Nothing, serviceId = Nothing, - handle = Nothing, expires = Nothing, teamId = Just tid, managedBy = ManagedByScim, diff --git a/services/brig/src/Brig/Provider/API.hs b/services/brig/src/Brig/Provider/API.hs index ace4335a31f..7c5089e9f3c 100644 --- a/services/brig/src/Brig/Provider/API.hs +++ b/services/brig/src/Brig/Provider/API.hs @@ -846,7 +846,6 @@ addBot zuid zcon cid add = do country = locale.lCountry, serviceId = Just sid, providerId = Just pid, - handle = Nothing, teamId = Nothing, managedBy = ManagedByWire, supportedProtocols = defSupportedProtocols, From 3309e7b8ecbbc70d987cea40a8e0162727598973 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 26 Aug 2026 10:02:12 +0200 Subject: [PATCH 107/113] [WPB-28089] Treat team collaborators like team members in contact search. (#5452) * Changelog. * Failing integration test. * Include team collaborators in contact search. * Implement TeamCollaboratorsSubsystem interpreter with BrigAPIAccess. Was previously UserSubsystem, but since it TeamCollaboratorsSubsystem is also used outside of Brig, that is not always available. Further changes: - Support BrigAPIAccess locally in Brig. - Change collaborator field type in UserDoc to collapse `Nothing` and `Just []` (remove the Maybe). * Remove stray TODO. (I don't understand what it is about, and there is no author to ask.) * Remove bogus TODO. (This end-point only updates searchability settings, nothing else.) * Make BrigAPIAccess.Local interpreter fall back on RPC. This is only where we don't expect to use it. if we're wrong about this, a warning will be logged. * Test users collaborating with more than one team. * make sanitize-pr --- ...rators-like-team-members-in-contact-search | 1 + integration/test/Test/TeamCollaborators.hs | 106 ++++++++ .../src/Wire/BrigAPIAccess/Local.hs | 65 +++++ .../src/Wire/BrigAPIAccess/Rpc.hs | 234 ++++++++++-------- .../IndexedUserStore/Bulk/ElasticSearch.hs | 22 +- .../Wire/IndexedUserStore/ElasticSearch.hs | 19 +- .../src/Wire/TeamCollaboratorsStore.hs | 2 + .../Wire/TeamCollaboratorsStore/Postgres.hs | 17 ++ .../TeamCollaboratorsSubsystem/Interpreter.hs | 36 ++- .../src/Wire/UserSearch/Types.hs | 9 +- .../src/Wire/UserStore/IndexUser.hs | 8 +- .../src/Wire/UserSubsystem/Interpreter.hs | 20 +- .../test/unit/Wire/MiniBackend.hs | 9 +- .../test/unit/Wire/MockInterpreters.hs | 1 + .../Wire/MockInterpreters/BrigAPIAccess.hs | 80 ++++++ .../TeamCollaboratorsStore.hs | 2 + .../Wire/MockInterpreters/UserSubsystem.hs | 2 +- .../Wire/ScimSubsystem/InterpreterSpec.hs | 2 +- .../test/unit/Wire/UserSearch/TypesSpec.hs | 3 +- .../Wire/UserSubsystem/InterpreterSpec.hs | 2 +- libs/wire-subsystems/wire-subsystems.cabal | 2 + .../background-worker/src/Wire/Effects.hs | 2 +- services/brig/src/Brig/App.hs | 6 + .../brig/src/Brig/CanonicalInterpreter.hs | 28 ++- services/brig/src/Brig/Index/Eval.hs | 7 + services/brig/src/Brig/User/Search/Index.hs | 9 + services/galley/src/Galley/App.hs | 2 +- 27 files changed, 539 insertions(+), 157 deletions(-) create mode 100644 changelog.d/1-api-changes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search create mode 100644 libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs create mode 100644 libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs diff --git a/changelog.d/1-api-changes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search b/changelog.d/1-api-changes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search new file mode 100644 index 00000000000..d66924aaeea --- /dev/null +++ b/changelog.d/1-api-changes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search @@ -0,0 +1 @@ +Treat team collaborators like team members in contact search. diff --git a/integration/test/Test/TeamCollaborators.hs b/integration/test/Test/TeamCollaborators.hs index cf55c3a558e..642dad537e8 100644 --- a/integration/test/Test/TeamCollaborators.hs +++ b/integration/test/Test/TeamCollaborators.hs @@ -1,3 +1,5 @@ +{-# OPTIONS_GHC -Wno-ambiguous-fields #-} + -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2025 Wire Swiss GmbH @@ -17,6 +19,9 @@ module Test.TeamCollaborators where +import qualified API.Brig as BrigP +import qualified API.BrigInternal as BrigI +import API.Common (randomName) import API.Galley import qualified API.GalleyInternal as Internal import Data.Tuple.Extra @@ -317,3 +322,104 @@ testUpdateCollaborator = do [] >>= assertSuccess postOne2OneConversation bob alice team "chit-chat" >>= assertLabel 403 "operation-denied" + +-- | Collaborators are part of the search space of the team they +-- collaborate with: `GET /search/contacts` returns them to members of +-- that team, just like it returns the team's own members. We test +-- collaborators from other teams, personal user accounts that +-- collaborate, and app. +testSearchFindsCollaborator :: (HasCallStack) => App () +testSearchFindsCollaborator = do + (owner, team, [alice]) <- createTeam OwnDomain 2 + (otherOwner, otherTeam, [bob, collab1]) <- createTeam OwnDomain 3 + collab2 :: Value <- randomUser OwnDomain def + collab3 :: Value <- + BrigP.createApp otherOwner otherTeam def + `bindResponse` \resp -> resp.json %. "user" + + collab1Name <- collab1 %. "name" & asString + collab2Name <- collab2 %. "name" & asString + collab3Name <- collab3 %. "name" & asString + + collab1Name' <- randomName + collab2Name' <- randomName + collab3Name' <- randomName + + -- Find before any collaborations have been established. + let assertFinds :: + (HasCallStack, MakesValue expectFound, MakesValue searcher) => + String -> + expectFound -> + searcher -> + App () + assertFinds searchTerm expectFound searcher = do + BrigI.refreshIndex OwnDomain + BrigP.searchContacts searcher searchTerm OwnDomain `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + foundIds :: [String] <- resp.json %. "documents" >>= asList >>= mapM objId + expectedIds :: [String] <- (make >=> asList >=> mapM objId) expectFound + assertBool + ("found: " <> show foundIds <> "; expected: " <> show expectedIds) + (sort foundIds == sort expectedIds) + + for_ [owner, alice] $ assertFinds collab1Name ([] @Value) + for_ [otherOwner, bob] $ assertFinds collab1Name [collab1] + + for_ [owner, alice] $ assertFinds collab2Name [collab2] + for_ [otherOwner, bob] $ assertFinds collab2Name [collab2] + + for_ [owner, alice] $ assertFinds collab3Name ([] @Value) + for_ [otherOwner, bob] $ assertFinds collab3Name [collab3] + + -- Add collaborators to team + for_ [collab1, collab2, collab3] + $ \collab -> + addTeamCollaborator owner team collab ["implicit_connection"] >>= assertSuccess + + for_ [owner, alice] $ assertFinds collab1Name [collab1] + for_ [otherOwner, bob] $ assertFinds collab1Name [collab1] + + for_ [owner, alice] $ assertFinds collab2Name [collab2] + for_ [otherOwner, bob] $ assertFinds collab2Name [collab2] + + for_ [owner, alice] $ assertFinds collab3Name [collab3] + for_ [otherOwner, bob] $ assertFinds collab3Name [collab3] + + -- Check that updating name does not erase collaborating teams in index. + for_ [(collab1, collab1Name'), (collab2, collab2Name'), (collab3, collab3Name')] + $ \(collab, newName) -> do + let updateBody = (def :: BrigP.PutSelf) {BrigP.name = Just newName} + in BrigP.putSelf collab updateBody >>= assertSuccess + + for_ [owner, alice] $ assertFinds collab1Name' [collab1] + for_ [otherOwner, bob] $ assertFinds collab1Name' [collab1] + + for_ [owner, alice] $ assertFinds collab2Name' [collab2] + for_ [otherOwner, bob] $ assertFinds collab2Name' [collab2] + + for_ [owner, alice] $ assertFinds collab3Name' [collab3] + for_ [otherOwner, bob] $ assertFinds collab3Name' [collab3] + + -- Check that updating collaborating teams does not erase name in index. + for_ [collab1, collab2, collab3] + $ \collab -> do + removeTeamCollaborator owner team collab >>= assertSuccess + + for_ [owner, alice] $ assertFinds collab1Name' ([] @Value) + for_ [otherOwner, bob] $ assertFinds collab1Name' [collab1] + + for_ [owner, alice] $ assertFinds collab2Name' [collab2] + for_ [otherOwner, bob] $ assertFinds collab2Name' [collab2] + + for_ [owner, alice] $ assertFinds collab3Name' ([] @Value) + for_ [otherOwner, bob] $ assertFinds collab3Name' [collab3] + + -- Can one user collaborate in multiple teams without breaking search? + (_thirdOwner, _thirdTeam, [multiCollab]) <- createTeam OwnDomain 2 + multiCollabName <- multiCollab %. "name" & asString + + addTeamCollaborator owner team multiCollab ["implicit_connection"] >>= assertSuccess + addTeamCollaborator otherOwner otherTeam multiCollab ["implicit_connection"] >>= assertSuccess + + for_ [owner, alice] $ assertFinds multiCollabName [multiCollab] + for_ [otherOwner, bob] $ assertFinds multiCollabName [multiCollab] diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs new file mode 100644 index 00000000000..43f04b0175c --- /dev/null +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs @@ -0,0 +1,65 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2025 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Interprets 'BrigAPIAccess' from within brig itself, by calling into the local +-- subsystems directly instead of round-tripping over HTTP to itself (as +-- 'Wire.BrigAPIAccess.Rpc.interpretBrigAccess' does for every other service). +-- +-- Only the operations needed by code shared with other services (e.g. +-- 'Wire.TeamCollaboratorsSubsystem') are implemented locally. Everything else +-- falls back to the RPC handler, pointed at brig itself: correct, but a wasteful +-- round-trip through our own listen socket, so it logs a warning and should be +-- given a local implementation once something actually relies on it. +module Wire.BrigAPIAccess.Local where + +import Imports +import Polysemy +import Polysemy.Error (Error) +import Polysemy.Input (runInputConst) +import Polysemy.TinyLog (TinyLog) +import Polysemy.TinyLog qualified as Log +import System.Logger.Message qualified as Log +import Util.Options (Endpoint) +import Wire.BrigAPIAccess +import Wire.BrigAPIAccess.Rpc (brigAccessRpcHandler) +import Wire.ParseException (ParseException) +import Wire.Rpc (Rpc) +import Wire.RpcException (RpcException) +import Wire.UserSubsystem (UserSubsystem) +import Wire.UserSubsystem qualified as UserSubsystem + +-- | The 'Endpoint' is brig's own; it is only used for the operations that have +-- no local implementation yet. +interpretBrigAPIAccessLocally :: + forall r. + ( Member TinyLog r, + Member Rpc r, + Member (Error ParseException) r, + Member (Error RpcException) r + ) => + Endpoint -> + InterpreterFor UserSubsystem r -> + InterpreterFor BrigAPIAccess r +interpretBrigAPIAccessLocally selfEndpoint runUser = interpret $ \case + UpdateSearchIndex uid -> runUser (UserSubsystem.internalUpdateSearchIndex uid) + other -> selfRpc other + where + selfRpc :: forall m x. BrigAPIAccess m x -> Sem r x + selfRpc action = do + Log.warn $ + Log.msg (Log.val "BrigAPIAccess.Local: no local implementation, calling brig over HTTP") + runInputConst selfEndpoint (brigAccessRpcHandler action) diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs index e42a4791392..b96d0abeea0 100644 --- a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs @@ -82,115 +82,131 @@ interpretBrigAccess :: Sem (BrigAPIAccess ': r) a -> Sem r a interpretBrigAccess brigEndpoint = - interpret $ - runInputConst brigEndpoint . \case - GetConnectionsUnqualified uids muids mrel -> do - getConnectionsUnqualified uids muids mrel - GetConnections uids mquids mrel -> do - getConnections uids mquids mrel - PutConnectionInternal uc -> do - putConnectionInternal uc - ReauthUser uid reauth -> do - reAuthUser uid reauth - LookupActivatedUsers uids -> do - lookupActivatedUsers uids - GetUsers uids -> do - getUsers uids - DeleteUser uid -> do - deleteUser uid - GetContactList uid -> do - getContactList uid - GetUserExportData uid -> do - getUserExportData uid - GetSize tid -> do - getSize tid - LookupClients uids -> do - lookupClients uids - LookupClientsFull uids -> do - lookupClientsFull uids - NotifyClientsAboutLegalHoldRequest self other pk -> do - notifyClientsAboutLegalHoldRequest self other pk - GetLegalHoldAuthToken uid mpwd -> do - getLegalHoldAuthToken uid mpwd - AddLegalHoldClientToUserEither uid conn pks lpk -> do - addLegalHoldClientToUser uid conn pks lpk - RemoveLegalHoldClientFromUser uid -> do - removeLegalHoldClientFromUser uid - GetAccountConferenceCallingConfigClient uid -> do - getAccountConferenceCallingConfigClient uid - GetLocalMLSClients qusr ss -> do - getLocalMLSClients qusr ss - GetLocalMLSClient qusr cid ss -> do - getLocalMLSClient qusr cid ss - UpdateSearchVisibilityInbound status -> do - updateSearchVisibilityInbound status - DeleteBot convId botId -> - deleteBot convId botId - UpdateSearchIndex uid -> updateSearchIndex uid - GetAccountsBy localGetBy -> - getAccountsBy localGetBy - GetUsersByVariousKeys uids handles emails includePendingInvitations -> - getUsersByVariousKeys uids handles emails includePendingInvitations - CreateGroupInternal managedBy teamId creatorUserId newGroup -> - createGroupInternal managedBy teamId creatorUserId newGroup - GetGroupsInternal tid mbFilter mbManagedBy startIndex mbCount -> - getGroupsInternal tid mbFilter mbManagedBy startIndex mbCount - GetGroupInternal tid gid includeChannels -> - getGroupInternal tid gid includeChannels - UpdateGroup req -> - updateGroup req - DeleteGroupInternal managedBy teamId groupId -> - deleteGroupInternal managedBy teamId groupId - GetAppIdsForTeam teamId -> - getAppIdsForTeam teamId - SetAccountStatus uid status -> - setAccountStatus uid status - DeleteApp teamId uid -> - deleteApp teamId uid - CreateSAML uref buid teamid name managedBy handle richInfo mLocale role -> - createSAML uref buid teamid name managedBy handle richInfo mLocale role - CreateNoSAML extId email uid teamid uname locale role -> - createNoSAML extId email uid teamid uname locale role - UpdateEmail uid email activation -> - updateEmail uid email activation - GetAccount havePending uid -> - getAccount havePending uid - GetAccountByHandle handle -> - getByHandle handle - GetByEmail email -> - getByEmail email - SetName uid name -> - setName uid name - SetHandle uid handle -> - setHandle uid handle - SetManagedBy uid managedBy -> - setManagedBy uid managedBy - DeletePendingEmailUpdate uid -> - deletePendingEmailUpdate uid - SetSSOId uid ssoId -> - setSSOId uid ssoId - SetRichInfo uid richInfo -> - setRichInfo uid richInfo - SetLocale uid mLocale -> - setLocale uid mLocale - GetRichInfo uid -> - getRichInfo uid - CheckHandleAvailable handle -> - checkHandleAvailable handle - SsoLogin uid mLabel -> - ssoLogin uid mLabel - GetStatus uid -> - getStatus uid - GetStatusMaybe uid -> - getStatusMaybe uid - SetStatus uid status -> - setStatus uid status - GetDefaultUserLocale -> - getDefaultUserLocale - CheckAdminGetTeamId uid -> - checkAdminGetTeamId uid - SendSAMLIdPChangedEmail notif -> - sendSAMLIdPChangedEmail notif + interpret $ runInputConst brigEndpoint . brigAccessRpcHandler + +-- | Handles a single 'BrigAPIAccess' action by calling brig over HTTP. +-- +-- Exposed separately from 'interpretBrigAccess' so that +-- 'Wire.BrigAPIAccess.Local.interpretBrigAPIAccessLocally' can delegate the +-- actions it does not implement itself. 'BrigAPIAccess' is a first-order +-- effect, so @m@ is unconstrained and any handler's action can be passed here. +brigAccessRpcHandler :: + ( Member TinyLog r, + Member Rpc r, + Member (Error ParseException) r, + Member (Error RpcException) r, + Member (Input Endpoint) r + ) => + BrigAPIAccess m a -> + Sem r a +brigAccessRpcHandler = \case + GetConnectionsUnqualified uids muids mrel -> do + getConnectionsUnqualified uids muids mrel + GetConnections uids mquids mrel -> do + getConnections uids mquids mrel + PutConnectionInternal uc -> do + putConnectionInternal uc + ReauthUser uid reauth -> do + reAuthUser uid reauth + LookupActivatedUsers uids -> do + lookupActivatedUsers uids + GetUsers uids -> do + getUsers uids + DeleteUser uid -> do + deleteUser uid + GetContactList uid -> do + getContactList uid + GetUserExportData uid -> do + getUserExportData uid + GetSize tid -> do + getSize tid + LookupClients uids -> do + lookupClients uids + LookupClientsFull uids -> do + lookupClientsFull uids + NotifyClientsAboutLegalHoldRequest self other pk -> do + notifyClientsAboutLegalHoldRequest self other pk + GetLegalHoldAuthToken uid mpwd -> do + getLegalHoldAuthToken uid mpwd + AddLegalHoldClientToUserEither uid conn pks lpk -> do + addLegalHoldClientToUser uid conn pks lpk + RemoveLegalHoldClientFromUser uid -> do + removeLegalHoldClientFromUser uid + GetAccountConferenceCallingConfigClient uid -> do + getAccountConferenceCallingConfigClient uid + GetLocalMLSClients qusr ss -> do + getLocalMLSClients qusr ss + GetLocalMLSClient qusr cid ss -> do + getLocalMLSClient qusr cid ss + UpdateSearchVisibilityInbound status -> do + updateSearchVisibilityInbound status + DeleteBot convId botId -> + deleteBot convId botId + UpdateSearchIndex uid -> updateSearchIndex uid + GetAccountsBy localGetBy -> + getAccountsBy localGetBy + GetUsersByVariousKeys uids handles emails includePendingInvitations -> + getUsersByVariousKeys uids handles emails includePendingInvitations + CreateGroupInternal managedBy teamId creatorUserId newGroup -> + createGroupInternal managedBy teamId creatorUserId newGroup + GetGroupsInternal tid mbFilter mbManagedBy startIndex mbCount -> + getGroupsInternal tid mbFilter mbManagedBy startIndex mbCount + GetGroupInternal tid gid includeChannels -> + getGroupInternal tid gid includeChannels + UpdateGroup req -> + updateGroup req + DeleteGroupInternal managedBy teamId groupId -> + deleteGroupInternal managedBy teamId groupId + GetAppIdsForTeam teamId -> + getAppIdsForTeam teamId + SetAccountStatus uid status -> + setAccountStatus uid status + DeleteApp teamId uid -> + deleteApp teamId uid + CreateSAML uref buid teamid name managedBy handle richInfo mLocale role -> + createSAML uref buid teamid name managedBy handle richInfo mLocale role + CreateNoSAML extId email uid teamid uname locale role -> + createNoSAML extId email uid teamid uname locale role + UpdateEmail uid email activation -> + updateEmail uid email activation + GetAccount havePending uid -> + getAccount havePending uid + GetAccountByHandle handle -> + getByHandle handle + GetByEmail email -> + getByEmail email + SetName uid name -> + setName uid name + SetHandle uid handle -> + setHandle uid handle + SetManagedBy uid managedBy -> + setManagedBy uid managedBy + DeletePendingEmailUpdate uid -> + deletePendingEmailUpdate uid + SetSSOId uid ssoId -> + setSSOId uid ssoId + SetRichInfo uid richInfo -> + setRichInfo uid richInfo + SetLocale uid mLocale -> + setLocale uid mLocale + GetRichInfo uid -> + getRichInfo uid + CheckHandleAvailable handle -> + checkHandleAvailable handle + SsoLogin uid mLabel -> + ssoLogin uid mLabel + GetStatus uid -> + getStatus uid + GetStatusMaybe uid -> + getStatusMaybe uid + SetStatus uid status -> + setStatus uid status + GetDefaultUserLocale -> + getDefaultUserLocale + CheckAdminGetTeamId uid -> + checkAdminGetTeamId uid + SendSAMLIdPChangedEmail notif -> + sendSAMLIdPChangedEmail notif brigRequest :: (Member Rpc r, Member (Input Endpoint) r) => (Request -> Request) -> Sem r (Response (Maybe LByteString)) brigRequest req = do diff --git a/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs b/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs index 6317ed7ba2d..e285ee4ad20 100644 --- a/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs +++ b/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs @@ -29,6 +29,7 @@ import Data.Conduit.List qualified as CL import Data.Id import Data.Json.Util (UTCTimeMillis (fromUTCTimeMillis)) import Data.Map qualified as Map +import Data.Set qualified as Set import Database.Bloodhound qualified as ES import Imports import Polysemy @@ -37,6 +38,7 @@ import Polysemy.TinyLog import Polysemy.TinyLog qualified as Log import System.Logger.Message qualified as Log import UnliftIO (pooledForConcurrentlyN) +import Wire.API.Team.Collaborator (gTeam, gUser) import Wire.API.Team.Feature import Wire.API.Team.Member.Info import Wire.API.Team.Role @@ -45,6 +47,7 @@ import Wire.IndexedUserStore (IndexedUserStore) import Wire.IndexedUserStore qualified as IndexedUserStore import Wire.IndexedUserStore.MigrationStore import Wire.IndexedUserStore.MigrationStore qualified as MigrationStore +import Wire.TeamCollaboratorsStore (TeamCollaboratorsStore, getTeamCollaborationsForUsers) import Wire.UserSearch.Migration import Wire.UserSearch.Types import Wire.UserStore @@ -54,15 +57,15 @@ type IOInterpreter r = forall a. Sem r a -> IO a -- | Increase this number any time you want to force reindexing. expectedMigrationVersion :: MigrationVersion -expectedMigrationVersion = MigrationVersion 6 +expectedMigrationVersion = MigrationVersion 7 -syncAllUsers :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r) => IOInterpreter r -> Int32 -> IO () +syncAllUsers :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore r) => IOInterpreter r -> Int32 -> IO () syncAllUsers interpreter pageSize = syncAllUsersWithVersion interpreter pageSize ES.ExternalGT -forceSyncAllUsers :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r) => IOInterpreter r -> Int32 -> IO () +forceSyncAllUsers :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore r) => IOInterpreter r -> Int32 -> IO () forceSyncAllUsers interpreter pageSize = syncAllUsersWithVersion interpreter pageSize ES.ExternalGTE -syncAllUsersWithVersion :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r) => IOInterpreter r -> Int32 -> (ES.ExternalDocVersion -> ES.VersionControl) -> IO () +syncAllUsersWithVersion :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore r) => IOInterpreter r -> Int32 -> (ES.ExternalDocVersion -> ES.VersionControl) -> IO () syncAllUsersWithVersion interpreter pageSize mkVersion = runConduit $ zipSources (CL.sourceList [1 ..]) (paginateWithStateC (interpreter . getIndexUsersPaginated pageSize)) @@ -114,6 +117,12 @@ syncAllUsersWithVersion interpreter pageSize mkVersion = fmap Map.unions . pooledForConcurrentlyN 16 (Map.toList teams) $ \(t, us) -> getRoles t (fmap (.userId) us) + -- One query for the whole page. A failure here fails every document of the + -- page, which 'logAndHush' then logs and skips. + eithCollabTeams :: Either SomeException (Map UserId [TeamId]) <- + try . fmap (Map.fromListWith (<>) . map (\tc -> (gUser tc, [gTeam tc]))) . interpreter $ + getTeamCollaborationsForUsers (Set.fromList (map (.userId) page)) + let vis :: IndexUser -> Either SomeException SearchVisibilityInbound vis indexUser = fromMaybe (Right defaultSearchVisibilityInbound) $ flip Map.lookup visMap =<< indexUser.teamId @@ -122,7 +131,8 @@ syncAllUsersWithVersion interpreter pageSize mkVersion = mkUserDoc indexUser = do currentVis <- vis indexUser currentRole <- sequence $ Map.lookup indexUser.userId roles - pure $ indexUserToDoc currentVis ((.value) <$> currentRole) indexUser + currentCollabTeams <- Map.findWithDefault [] indexUser.userId <$> eithCollabTeams + pure $ indexUserToDoc currentVis ((.value) <$> currentRole) currentCollabTeams indexUser mkDocVersion :: IndexUser -> Either SomeException ES.VersionControl mkDocVersion u = do @@ -159,7 +169,7 @@ syncAllUsersWithVersion interpreter pageSize mkVersion = <$> permissionsToRole tmi.permissions migrateData :: - (Member (Embed IO) r, Member IndexedUserStore r, Member (Error MigrationException) r, Member IndexedUserMigrationStore r, Member TinyLog r, Member UserStore r, Member GalleyAPIAccess r) => + (Member (Embed IO) r, Member IndexedUserStore r, Member (Error MigrationException) r, Member IndexedUserMigrationStore r, Member TinyLog r, Member UserStore r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore r) => IOInterpreter r -> Int32 -> IO () diff --git a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs index 156f8f6e479..07572a29851 100644 --- a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs +++ b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs @@ -526,13 +526,17 @@ matchSelf :: UserId -> Maybe ES.Query matchSelf searcher = Just (termQ "_id" (idToText searcher)) -- | Exclude apps from other teams. --- Apps should only be searchable within their own team. +-- Apps should only be searchable within their own team, or within a team they +-- collaborate with. matchAppsFromOtherTeams :: Maybe TeamId -> Maybe ES.Query matchAppsFromOtherTeams mSearcherTeamId = Just $ ES.QueryBoolQuery boolQuery - { ES.boolQueryMustMatch = + { -- Apps collaborating with the searcher's team are not excluded. + ES.boolQueryMustNotMatch = + maybeToList (termQ "collaborating_teams" . idToText <$> mSearcherTeamId), + ES.boolQueryMustMatch = [ -- Match apps (type = "app") termQ "type" "app", -- That are from a different team than the searcher @@ -640,7 +644,16 @@ restrictSearchSpaceByUserType = \case else ES.TermsQuery "type" (userTypeFilterToText <$> (utsH :| utsT)) matchTeamMembersOf :: TeamId -> ES.Query -matchTeamMembersOf team = ES.TermQuery (ES.Term "team" $ idToText team) Nothing +matchTeamMembersOf team = + ES.QueryBoolQuery + boolQuery + { ES.boolQueryShouldMatch = + [ -- Match users who are members of the team + ES.TermQuery (ES.Term "team" $ idToText team) Nothing, + -- Match users who are collaborators in the team + ES.TermQuery (ES.Term "collaborating_teams" $ idToText team) Nothing + ] + } matchTeamMembersSearchableByAllTeams :: ES.Query matchTeamMembersSearchableByAllTeams = diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs index fcdf0731b08..ebf79c96721 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs @@ -29,6 +29,8 @@ data TeamCollaboratorsStore m a where GetAllTeamCollaborators :: TeamId -> TeamCollaboratorsStore m [TeamCollaborator] GetTeamCollaborator :: TeamId -> UserId -> TeamCollaboratorsStore m (Maybe TeamCollaborator) GetTeamCollaborations :: UserId -> TeamCollaboratorsStore m ([TeamCollaborator]) + -- | Batched 'GetTeamCollaborations', for callers that process users in pages. + GetTeamCollaborationsForUsers :: Set UserId -> TeamCollaboratorsStore m [TeamCollaborator] GetTeamCollaboratorsWithIds :: Set TeamId -> Set UserId -> TeamCollaboratorsStore m [TeamCollaborator] UpdateTeamCollaborator :: UserId -> TeamId -> Set CollaboratorPermission -> TeamCollaboratorsStore m () RemoveTeamCollaborator :: UserId -> TeamId -> TeamCollaboratorsStore m () diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs index a6a1e968a72..b898ae69b51 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs @@ -49,6 +49,7 @@ interpretTeamCollaboratorsStoreToPostgres = GetAllTeamCollaborators teamId -> getAllTeamCollaboratorsImpl teamId GetTeamCollaborator teamId userId -> getTeamCollaboratorImpl teamId userId GetTeamCollaborations userId -> getTeamCollaborationsImpl userId + GetTeamCollaborationsForUsers userIds -> getTeamCollaborationsForUsersImpl userIds GetTeamCollaboratorsWithIds teamIds userIds -> getTeamCollaboratorsWithIdsImpl teamIds userIds UpdateTeamCollaborator userId teamId permissions -> updateTeamCollaboratorImpl userId teamId permissions RemoveTeamCollaborator userId teamId -> removeTeamCollaboratorImpl userId teamId @@ -181,6 +182,22 @@ getTeamCollaborationsImpl teamId = do select user_id :: uuid, team_id :: uuid, permissions :: int2[] from collaborators where user_id = ($1 :: uuid) |] +getTeamCollaborationsForUsersImpl :: + (PGConstraints r) => + Set UserId -> + Sem r [TeamCollaborator] +getTeamCollaborationsForUsersImpl userIds = do + runStatement (Data.Set.toList userIds) getAllCollaborationsByUsersStatement + where + getAllCollaborationsByUsersStatement :: Statement [UserId] [TeamCollaborator] + getAllCollaborationsByUsersStatement = + dimap + (Data.Vector.fromList . Imports.map toUUID) + (Data.Vector.toList . (toTeamCollaborator <$>)) + $ [vectorStatement| + select user_id :: uuid, team_id :: uuid, permissions :: int2[] from collaborators where user_id = ANY($1 :: uuid[]) + |] + getTeamCollaboratorsWithIdsImpl :: (PGConstraints r) => Set TeamId -> diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs index 30a970706e3..bb0541636b0 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs @@ -29,6 +29,8 @@ import Wire.API.Error.Brig qualified as E import Wire.API.Event.Team import Wire.API.Team.Collaborator import Wire.API.Team.Member qualified as TeamMember +import Wire.BrigAPIAccess (BrigAPIAccess) +import Wire.BrigAPIAccess qualified as BrigAPIAccess import Wire.Error import Wire.NotificationSubsystem import Wire.Sem.Now @@ -44,15 +46,18 @@ interpretTeamCollaboratorsSubsystem :: Member Now r, Member NotificationSubsystem r ) => + InterpreterFor BrigAPIAccess r -> InterpreterFor TeamCollaboratorsSubsystem r -interpretTeamCollaboratorsSubsystem = interpret $ \case - CreateTeamCollaborator zUser user team perms -> createTeamCollaboratorImpl zUser user team perms - GetAllTeamCollaborators zUser team -> getAllTeamCollaboratorsImpl zUser team - InternalGetTeamCollaborator team user -> internalGetTeamCollaboratorImpl team user - InternalGetTeamCollaborations userId -> internalGetTeamCollaborationsImpl userId - InternalGetTeamCollaboratorsWithIds teams userIds -> internalGetTeamCollaboratorsWithIdsImpl teams userIds - InternalUpdateTeamCollaborator user team perms -> internalUpdateTeamCollaboratorImpl user team perms - InternalRemoveTeamCollaborator user team -> internalRemoveTeamCollaboratorImpl user team +interpretTeamCollaboratorsSubsystem brigAPIAccess = + interpret $ + brigAPIAccess . \case + CreateTeamCollaborator zUser user team perms -> createTeamCollaboratorImpl zUser user team perms + GetAllTeamCollaborators zUser team -> getAllTeamCollaboratorsImpl zUser team + InternalGetTeamCollaborator team user -> internalGetTeamCollaboratorImpl team user + InternalGetTeamCollaborations userId -> internalGetTeamCollaborationsImpl userId + InternalGetTeamCollaboratorsWithIds teams userIds -> internalGetTeamCollaboratorsWithIdsImpl teams userIds + InternalUpdateTeamCollaborator user team perms -> internalUpdateTeamCollaboratorImpl user team perms + InternalRemoveTeamCollaborator user team -> internalRemoveTeamCollaboratorImpl user team internalGetTeamCollaboratorImpl :: (Member Store.TeamCollaboratorsStore r) => @@ -74,7 +79,8 @@ createTeamCollaboratorImpl :: Member (Error TeamCollaboratorsError) r, Member Store.TeamCollaboratorsStore r, Member Now r, - Member NotificationSubsystem r + Member NotificationSubsystem r, + Member BrigAPIAccess r ) => Local UserId -> UserId -> @@ -85,9 +91,11 @@ createTeamCollaboratorImpl zUser user team perms = do guardPermission (tUnqualified zUser) team TeamMember.GetTeamCollaborators InsufficientRights Store.createTeamCollaborator user team perms - -- TODO: Review the event's values generateTeamEvents (tUnqualified zUser) team [EdCollaboratorAdd user (Set.toList perms)] + -- Reindex the collaborator with their new collaboration team + BrigAPIAccess.updateSearchIndex user + getAllTeamCollaboratorsImpl :: ( Member TeamSubsystem r, Member (Error TeamCollaboratorsError) r, @@ -109,21 +117,25 @@ internalGetTeamCollaboratorsWithIdsImpl = do Store.getTeamCollaboratorsWithIds internalUpdateTeamCollaboratorImpl :: - (Member Store.TeamCollaboratorsStore r) => + (Member Store.TeamCollaboratorsStore r, Member BrigAPIAccess r) => UserId -> TeamId -> Set CollaboratorPermission -> Sem r () internalUpdateTeamCollaboratorImpl user team perms = do Store.updateTeamCollaborator user team perms + -- Reindex collaborator when permissions change + BrigAPIAccess.updateSearchIndex user internalRemoveTeamCollaboratorImpl :: - (Member Store.TeamCollaboratorsStore r) => + (Member Store.TeamCollaboratorsStore r, Member BrigAPIAccess r) => UserId -> TeamId -> Sem r () internalRemoveTeamCollaboratorImpl user team = do Store.removeTeamCollaborator user team + -- Reindex collaborator when removed + BrigAPIAccess.updateSearchIndex user -- This is of general usefulness. However, we cannot move this to wire-api as -- this would lead to a cyclic dependency. diff --git a/libs/wire-subsystems/src/Wire/UserSearch/Types.hs b/libs/wire-subsystems/src/Wire/UserSearch/Types.hs index 5e8dcac765e..5464dae2a8f 100644 --- a/libs/wire-subsystems/src/Wire/UserSearch/Types.hs +++ b/libs/wire-subsystems/src/Wire/UserSearch/Types.hs @@ -79,7 +79,10 @@ data UserDoc = UserDoc udScimExternalId :: Maybe Text, udSso :: Maybe Sso, udEmailUnvalidated :: Maybe EmailAddress, - udSearchable :: Maybe Bool + udSearchable :: Maybe Bool, + -- | Teams that have added this user as a collaborator. + -- Updated separately via 'syncUserIndexCollaborations' when collaborator relationships change. + udCollaboratingTeams :: [TeamId] } deriving (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform UserDoc) @@ -104,7 +107,8 @@ instance ToJSON UserDoc where "scim_external_id" .= udScimExternalId ud, "sso" .= udSso ud, "email_unvalidated" .= udEmailUnvalidated ud, - "searchable" .= udSearchable ud + "searchable" .= udSearchable ud, + "collaborating_teams" .= udCollaboratingTeams ud ] instance FromJSON UserDoc where @@ -128,6 +132,7 @@ instance FromJSON UserDoc where <*> o .:? "sso" <*> o .:? "email_unvalidated" <*> o .:? "searchable" + <*> o .:? "collaborating_teams" .!= [] searchVisibilityInboundFieldName :: Key searchVisibilityInboundFieldName = "search_visibility_inbound" diff --git a/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs b/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs index 09ac630d191..b051132f1ed 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs @@ -126,8 +126,8 @@ indexUserToVersion :: Maybe (WithWritetime Role) -> IndexUser -> IndexVersion indexUserToVersion role iu = mkIndexVersion [Just $ Writetime iu.updatedAt, const () <$$> fmap writetime role] -indexUserToDoc :: SearchVisibilityInbound -> Maybe Role -> IndexUser -> UserDoc -indexUserToDoc searchVisInbound mRole IndexUser {..} = +indexUserToDoc :: SearchVisibilityInbound -> Maybe Role -> [TeamId] -> IndexUser -> UserDoc +indexUserToDoc searchVisInbound mRole collaboratingTeams IndexUser {..} = if shouldIndex then UserDoc @@ -148,7 +148,8 @@ indexUserToDoc searchVisInbound mRole IndexUser {..} = udHandle = handle, udNormalized = Just $ normalized name.fromName, udName = Just name, - udTeam = teamId + udTeam = teamId, + udCollaboratingTeams = collaboratingTeams } else -- We insert a tombstone-style user here, as it's easier than -- deleting the old one. It's mostly empty, but having the status here @@ -209,5 +210,6 @@ emptyUserDoc uid = udNormalized = Nothing, udName = Nothing, udTeam = Nothing, + udCollaboratingTeams = [], udId = uid } diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index d5cb2dfee62..f78029cebde 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -60,6 +60,7 @@ import Wire.API.Federation.Error import Wire.API.MLS.CipherSuite (CipherSuiteTag, csSignatureScheme) import Wire.API.Routes.FederationDomainConfig import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti (TeamStatus (..)) +import Wire.API.Team.Collaborator (gTeam) import Wire.API.Team.Export import Wire.API.Team.Feature import Wire.API.Team.Member @@ -102,6 +103,8 @@ import Wire.Sem.Metrics qualified as Metrics import Wire.Sem.Now (Now) import Wire.Sem.Now qualified as Now import Wire.StoredUser +import Wire.TeamCollaboratorsStore (TeamCollaboratorsStore) +import Wire.TeamCollaboratorsStore qualified as TeamCollaboratorsStore import Wire.TeamSubsystem import Wire.UserGroupStore (UserGroupStore, getUserGroupIdsForUsers) import Wire.UserKeyStore @@ -141,6 +144,7 @@ runUserSubsystem :: Member TinyLog r, Member (Input UserSubsystemConfig) r, Member TeamSubsystem r, + Member TeamCollaboratorsStore r, Member UserGroupStore r, Member (Input (Local any)) r ) => @@ -711,6 +715,7 @@ updateUserProfileImpl :: Member Events r, Member GalleyAPIAccess r, Member IndexedUserStore r, + Member TeamCollaboratorsStore r, Member Metrics r ) => Local UserId -> @@ -772,6 +777,7 @@ updateHandleImpl :: Member Events r, Member UserStore r, Member IndexedUserStore r, + Member TeamCollaboratorsStore r, Member Metrics r ) => Local UserId -> @@ -839,7 +845,8 @@ syncUserIndex :: ( Member UserStore r, Member GalleyAPIAccess r, Member IndexedUserStore r, - Member Metrics r + Member Metrics r, + Member TeamCollaboratorsStore r ) => UserId -> Sem r () @@ -860,9 +867,13 @@ syncUserIndex uid = teamSearchVisibilityInbound indexUser.teamId tm <- maybe (pure Nothing) selectTeamMember indexUser.teamId + collabTeams <- map gTeam <$> TeamCollaboratorsStore.getTeamCollaborations uid let mRole = tm >>= mkRoleWithWriteTime - userDoc = indexUserToDoc vis (value <$> mRole) indexUser - version = ES.ExternalGT . ES.ExternalDocVersion . docVersion $ indexUserToVersion mRole indexUser + userDoc = indexUserToDoc vis (value <$> mRole) collabTeams indexUser + -- GTE, not GT: the version comes from the user row alone, but the document also + -- holds data that changes without touching that row (collaborations), and under + -- GT those updates would be dropped as version conflicts. Older writes still lose. + version = ES.ExternalGTE . ES.ExternalDocVersion . docVersion $ indexUserToVersion mRole indexUser Metrics.incCounter indexUpdateCounter IndexedUserStore.upsert (userIdToDocId uid) userDoc version @@ -1180,6 +1191,7 @@ acceptTeamInvitationImpl :: Member (Error UserSubsystemError) r, Member InvitationStore r, Member IndexedUserStore r, + Member TeamCollaboratorsStore r, Member Metrics r, Member Events r, Member AuthenticationSubsystem r, @@ -1244,6 +1256,7 @@ removeEmailEitherImpl :: Member UserStore r, Member Events r, Member IndexedUserStore r, + Member TeamCollaboratorsStore r, Member (Input UserSubsystemConfig) r, Member GalleyAPIAccess r, Member Metrics r @@ -1280,6 +1293,7 @@ setUserSearchableImpl :: Member TeamSubsystem r, Member GalleyAPIAccess r, Member IndexedUserStore r, + Member TeamCollaboratorsStore r, Member Metrics r ) => Local UserId -> diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index 1324d919db3..ff27a27fa7f 100644 --- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs +++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs @@ -365,22 +365,21 @@ miniBackendLowerEffectsInterpreters mb@(MiniBackendParams {..}) = . runInputConst conversationCfg . runClientSubsystem undefined undefined where - -- Mock BrigAPIAccess interpreter for tests - mockBrigAPIAccess :: forall r'. InterpreterFor BrigAPIAccess r' - mockBrigAPIAccess = interpret $ \case - _ -> error "Unimplemented BrigAPIAccess operation in mock" -- Mock UserClientIndexStore interpreter for tests mockUserClientIndexStore :: forall r'. InterpreterFor UserClientIndexStore r' mockUserClientIndexStore = interpret $ \case _ -> error "Unimplemented UserClientIndexStore operation in mock" + -- Mock BackendNotificationQueueAccess interpreter for tests mockBackendNotificationQueueAccess :: forall r'. InterpreterFor BackendNotificationQueueAccess r' mockBackendNotificationQueueAccess = interpret $ \case _ -> error "Unimplemented BackendNotificationQueueAccess operation in mock" + -- Mock ConversationSubsystem interpreter for tests mockConversationSubsystem :: forall r'. InterpreterFor ConversationSubsystem r' mockConversationSubsystem = interpretH $ \case _ -> error "Unimplemented ConversationSubsystem operation in mock" + mockMlsKeyPackageSubsystem :: forall r'. InterpreterFor MlsKeyPackageSubsystem r' mockMlsKeyPackageSubsystem = interpret $ \case HasMlsKeyPackages {} -> pure False @@ -786,7 +785,7 @@ interpretMaybeFederationStackState :: Sem (MiniBackendEffects `Append` r) a -> Sem r (MiniBackend, a) interpretMaybeFederationStackState mb = - miniBackendLowerEffectsInterpreters mb . interpretTeamCollaboratorsSubsystem . runRecursiveAuthUserApp + miniBackendLowerEffectsInterpreters mb . interpretTeamCollaboratorsSubsystem subsume . runRecursiveAuthUserApp -- FUTUREWORK(fisx): it would be nice to have a definition of an -- interpreter of all the subsystems combined, but since the diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs index 4630c0c7f77..ea57140aad5 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs @@ -25,6 +25,7 @@ import Wire.MockInterpreters.AppStore as MockInterpreters import Wire.MockInterpreters.AuthenticationSubsystem as MockInterpreters import Wire.MockInterpreters.BackgroundJobPublisher as MockInterpreters import Wire.MockInterpreters.BlockListStore as MockInterpreters +import Wire.MockInterpreters.BrigAPIAccess as MockInterpreters import Wire.MockInterpreters.ClientStore as MockInterpreters import Wire.MockInterpreters.ConversationStore as MockInterpreters import Wire.MockInterpreters.ConversationSubsystem as MockInterpreters diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs new file mode 100644 index 00000000000..5aacaddac97 --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs @@ -0,0 +1,80 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2025 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MockInterpreters.BrigAPIAccess where + +import Imports +import Polysemy +import Wire.BrigAPIAccess + +-- | Errors out on everything except 'UpdateSearchIndex', which is a no-op. +mockBrigAPIAccess :: InterpreterFor BrigAPIAccess r +mockBrigAPIAccess = interpret $ \case + GetConnectionsUnqualified {} -> error "GetConnectionsUnqualified: implement on demand (mockBrigAPIAccess)" + GetConnections {} -> error "GetConnections: implement on demand (mockBrigAPIAccess)" + PutConnectionInternal {} -> error "PutConnectionInternal: implement on demand (mockBrigAPIAccess)" + ReauthUser {} -> error "ReauthUser: implement on demand (mockBrigAPIAccess)" + LookupActivatedUsers {} -> error "LookupActivatedUsers: implement on demand (mockBrigAPIAccess)" + GetUsers {} -> error "GetUsers: implement on demand (mockBrigAPIAccess)" + DeleteUser {} -> error "DeleteUser: implement on demand (mockBrigAPIAccess)" + GetContactList {} -> error "GetContactList: implement on demand (mockBrigAPIAccess)" + GetSize {} -> error "GetSize: implement on demand (mockBrigAPIAccess)" + LookupClients {} -> error "LookupClients: implement on demand (mockBrigAPIAccess)" + LookupClientsFull {} -> error "LookupClientsFull: implement on demand (mockBrigAPIAccess)" + NotifyClientsAboutLegalHoldRequest {} -> error "NotifyClientsAboutLegalHoldRequest: implement on demand (mockBrigAPIAccess)" + GetLegalHoldAuthToken {} -> error "GetLegalHoldAuthToken: implement on demand (mockBrigAPIAccess)" + AddLegalHoldClientToUserEither {} -> error "AddLegalHoldClientToUserEither: implement on demand (mockBrigAPIAccess)" + RemoveLegalHoldClientFromUser {} -> error "RemoveLegalHoldClientFromUser: implement on demand (mockBrigAPIAccess)" + GetAccountConferenceCallingConfigClient {} -> error "GetAccountConferenceCallingConfigClient: implement on demand (mockBrigAPIAccess)" + GetLocalMLSClients {} -> error "GetLocalMLSClients: implement on demand (mockBrigAPIAccess)" + GetLocalMLSClient {} -> error "GetLocalMLSClient: implement on demand (mockBrigAPIAccess)" + UpdateSearchVisibilityInbound {} -> error "UpdateSearchVisibilityInbound: implement on demand (mockBrigAPIAccess)" + GetUserExportData {} -> error "GetUserExportData: implement on demand (mockBrigAPIAccess)" + DeleteBot {} -> error "DeleteBot: implement on demand (mockBrigAPIAccess)" + UpdateSearchIndex _ -> pure () + GetAccountsBy {} -> error "GetAccountsBy: implement on demand (mockBrigAPIAccess)" + GetUsersByVariousKeys {} -> error "GetUsersByVariousKeys: implement on demand (mockBrigAPIAccess)" + CreateGroupInternal {} -> error "CreateGroupInternal: implement on demand (mockBrigAPIAccess)" + GetGroupInternal {} -> error "GetGroupInternal: implement on demand (mockBrigAPIAccess)" + GetGroupsInternal {} -> error "GetGroupsInternal: implement on demand (mockBrigAPIAccess)" + UpdateGroup {} -> error "UpdateGroup: implement on demand (mockBrigAPIAccess)" + DeleteGroupInternal {} -> error "DeleteGroupInternal: implement on demand (mockBrigAPIAccess)" + DeleteApp {} -> error "DeleteApp: implement on demand (mockBrigAPIAccess)" + GetAppIdsForTeam {} -> error "GetAppIdsForTeam: implement on demand (mockBrigAPIAccess)" + SetAccountStatus {} -> error "SetAccountStatus: implement on demand (mockBrigAPIAccess)" + CreateSAML {} -> error "CreateSAML: implement on demand (mockBrigAPIAccess)" + CreateNoSAML {} -> error "CreateNoSAML: implement on demand (mockBrigAPIAccess)" + UpdateEmail {} -> error "UpdateEmail: implement on demand (mockBrigAPIAccess)" + GetAccount {} -> error "GetAccount: implement on demand (mockBrigAPIAccess)" + GetAccountByHandle {} -> error "GetAccountByHandle: implement on demand (mockBrigAPIAccess)" + GetByEmail {} -> error "GetByEmail: implement on demand (mockBrigAPIAccess)" + SetName {} -> error "SetName: implement on demand (mockBrigAPIAccess)" + SetHandle {} -> error "SetHandle: implement on demand (mockBrigAPIAccess)" + SetManagedBy {} -> error "SetManagedBy: implement on demand (mockBrigAPIAccess)" + DeletePendingEmailUpdate {} -> error "DeletePendingEmailUpdate: implement on demand (mockBrigAPIAccess)" + SetSSOId {} -> error "SetSSOId: implement on demand (mockBrigAPIAccess)" + SetRichInfo {} -> error "SetRichInfo: implement on demand (mockBrigAPIAccess)" + SetLocale {} -> error "SetLocale: implement on demand (mockBrigAPIAccess)" + GetRichInfo {} -> error "GetRichInfo: implement on demand (mockBrigAPIAccess)" + CheckHandleAvailable {} -> error "CheckHandleAvailable: implement on demand (mockBrigAPIAccess)" + SsoLogin {} -> error "SsoLogin: implement on demand (mockBrigAPIAccess)" + GetStatus {} -> error "GetStatus: implement on demand (mockBrigAPIAccess)" + GetStatusMaybe {} -> error "GetStatusMaybe: implement on demand (mockBrigAPIAccess)" + SetStatus {} -> error "SetStatus: implement on demand (mockBrigAPIAccess)" + GetDefaultUserLocale {} -> error "GetDefaultUserLocale: implement on demand (mockBrigAPIAccess)" + CheckAdminGetTeamId {} -> error "CheckAdminGetTeamId: implement on demand (mockBrigAPIAccess)" + SendSAMLIdPChangedEmail {} -> error "SendSAMLIdPChangedEmail: implement on demand (mockBrigAPIAccess)" diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs index 4def51eeef7..63a334527ab 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs @@ -41,6 +41,8 @@ inMemoryTeamCollaboratorsStoreInterpreter = gets $ \(s :: Map TeamId [TeamCollaborator]) -> find (\tc -> tc.gUser == userId) =<< Map.lookup teamId s GetTeamCollaborations userId -> gets $ \(s :: Map TeamId [TeamCollaborator]) -> concatMap (filter (\tc -> tc.gUser == userId)) (Map.elems s) + GetTeamCollaborationsForUsers userIds -> + gets $ \(s :: Map TeamId [TeamCollaborator]) -> concatMap (filter (\tc -> tc.gUser `elem` userIds)) (Map.elems s) GetTeamCollaboratorsWithIds teamIds userIds -> gets $ \(s :: Map TeamId [TeamCollaborator]) -> concatMap (concatMap (filter (\tc -> tc.gUser `elem` userIds)) . (\(tid :: TeamId) -> Map.lookup tid s)) teamIds diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs index 786f733240f..043f3a834db 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs @@ -87,7 +87,7 @@ inMemoryUserSubsystemInterpreter = BlockListInsert _ -> error "BlockListInsert: implement on demand (userSubsystemInterpreter)" UpdateTeamSearchVisibilityInbound _ -> error "UpdateTeamSearchVisibilityInbound: implement on demand (userSubsystemInterpreter)" AcceptTeamInvitation {} -> error "AcceptTeamInvitation: implement on demand (userSubsystemInterpreter)" - InternalUpdateSearchIndex _ -> error "InternalUpdateSearchIndex: implement on demand (userSubsystemInterpreter)" + InternalUpdateSearchIndex {} -> error "InternalUpdateSearchIndex: implement on demand (userSubsystemInterpreter)" InternalFindTeamInvitation {} -> error "InternalFindTeamInvitation: implement on demand (userSubsystemInterpreter)" GetUserExportData _ -> error "GetUserExportData: implement on demand (userSubsystemInterpreter)" RemoveEmailEither _ -> error "RemoveEmailEither: implement on demand (userSubsystemInterpreter)" diff --git a/libs/wire-subsystems/test/unit/Wire/ScimSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ScimSubsystem/InterpreterSpec.hs index 6861f097797..f623e0012dd 100644 --- a/libs/wire-subsystems/test/unit/Wire/ScimSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ScimSubsystem/InterpreterSpec.hs @@ -42,7 +42,7 @@ import Wire.API.User as User import Wire.API.User.Scim import Wire.API.UserGroup import Wire.BrigAPIAccess (BrigAPIAccess (..)) -import Wire.MockInterpreters +import Wire.MockInterpreters hiding (mockBrigAPIAccess) import Wire.ScimSubsystem import Wire.ScimSubsystem.Interpreter import Wire.StoredUser diff --git a/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs index a09d56bd8ff..0a18b9d62fb 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs @@ -50,6 +50,7 @@ userDoc1 = UserDoc { udId = fromJust . hush . parseIdFromText $ "0a96b396-57d6-11ea-a04b-7b93d1a5c19c", udTeam = hush . parseIdFromText $ "17c59b18-57d6-11ea-9220-8bbf5eee961a", + udCollaboratingTeams = either (error . show) (: []) . parseIdFromText $ "17c59b18-57d6-11ea-9220-8bbf5eee961a", udName = Just . Name $ "Carl Phoomp", udNormalized = Just $ "carl phoomp", udHandle = Just . fromJust . parseHandle $ "phoompy", @@ -70,4 +71,4 @@ userDoc1 = -- Dont touch this. This represents serialized legacy data. userDoc1ByteString :: LByteString -userDoc1ByteString = "{\"email\":\"phoompy@example.com\",\"account_status\":\"active\",\"handle\":\"phoompy\",\"managed_by\":\"scim\",\"role\":\"admin\",\"accent_id\":32,\"name\":\"Carl Phoomp\",\"created_at\":\"2020-08-29T21:50:00.000Z\",\"team\":\"17c59b18-57d6-11ea-9220-8bbf5eee961a\",\"id\":\"0a96b396-57d6-11ea-a04b-7b93d1a5c19c\",\"normalized\":\"carl phoomp\",\"saml_idp\":\"https://issuer.net/214234\"}" +userDoc1ByteString = "{\"collaborating_teams\":[\"17c59b18-57d6-11ea-9220-8bbf5eee961a\"],\"email\":\"phoompy@example.com\",\"account_status\":\"active\",\"handle\":\"phoompy\",\"managed_by\":\"scim\",\"role\":\"admin\",\"accent_id\":32,\"name\":\"Carl Phoomp\",\"created_at\":\"2020-08-29T21:50:00.000Z\",\"team\":\"17c59b18-57d6-11ea-9220-8bbf5eee961a\",\"id\":\"0a96b396-57d6-11ea-a04b-7b93d1a5c19c\",\"normalized\":\"carl phoomp\",\"saml_idp\":\"https://issuer.net/214234\"}" diff --git a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs index 68942a77cb3..9e309d50dda 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs @@ -1110,7 +1110,7 @@ spec = describe "UserSubsystem.Interpreter" do searchee = searcheeNoHandle {handle = Just searcheeHandle} :: StoredUser storedUserToDoc :: StoredUser -> UserDoc - storedUserToDoc user = indexUserToDoc defaultSearchVisibilityInbound Nothing (storedUserToIndexUser user) + storedUserToDoc user = indexUserToDoc defaultSearchVisibilityInbound Nothing [] (storedUserToIndexUser user) indexFromStoredUsers :: [StoredUser] -> UserIndex indexFromStoredUsers storedUsers = do diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index f7627036f1c..42196d6e18c 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -244,6 +244,7 @@ library Wire.BoundedQueue Wire.BoundedQueue.STM Wire.BrigAPIAccess + Wire.BrigAPIAccess.Local Wire.BrigAPIAccess.Rpc Wire.BudgetStore Wire.BudgetStore.Cassandra @@ -650,6 +651,7 @@ test-suite wire-subsystems-tests Wire.MockInterpreters.AuthenticationSubsystem Wire.MockInterpreters.BackgroundJobPublisher Wire.MockInterpreters.BlockListStore + Wire.MockInterpreters.BrigAPIAccess Wire.MockInterpreters.ClientStore Wire.MockInterpreters.ConversationStore Wire.MockInterpreters.ConversationSubsystem diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index 0d367f19eed..66dbee09c56 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -367,7 +367,7 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = ) . runFeaturesConfigSubsystem . runInputSem getAllTeamFeaturesForServer - . interpretTeamCollaboratorsSubsystem + . interpretTeamCollaboratorsSubsystem (interpretBrigAccess env.brigEndpoint) . discardMeetingNotifier . interpretConversationSubsystem where diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index 6c2145ea8cd..27812d06b47 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -36,6 +36,7 @@ module Brig.App cargoholdLens, galleyLens, galleyEndpointLens, + brigEndpointLens, sparEndpointLens, gundeckEndpointLens, cargoholdEndpointLens, @@ -189,6 +190,10 @@ data Env = Env { cargohold :: RPC.Request, galley :: RPC.Request, galleyEndpoint :: Endpoint, + -- | Brig's own listen address. Used only to call ourselves over HTTP for + -- 'BrigAPIAccess' operations that have no local implementation yet; see + -- 'Wire.BrigAPIAccess.Local'. + brigEndpoint :: Endpoint, sparEndpoint :: Endpoint, gundeckEndpoint :: Endpoint, cargoholdEndpoint :: Endpoint, @@ -307,6 +312,7 @@ newEnv opts = do { cargohold = mkEndpoint $ opts.cargohold, galley = mkEndpoint $ opts.galley, galleyEndpoint = opts.galley, + brigEndpoint = opts.brig, sparEndpoint = opts.spar, gundeckEndpoint = opts.gundeck, cargoholdEndpoint = opts.cargohold, diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index f866fc5a9ca..4414567c910 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -47,6 +47,7 @@ import Polysemy.Input (Input, runInputConst) import Polysemy.Internal.Kind import Polysemy.Resource import Polysemy.TinyLog (TinyLog) +import Util.Options (Endpoint) import Wire.API.Error (ErrorS, errorToWai) import Wire.API.Error.Galley import Wire.API.Federation.Client qualified @@ -68,6 +69,8 @@ import Wire.BackgroundJobsPublisher (BackgroundJobPublisher) import Wire.BackgroundJobsPublisher.RabbitMQ (interpretBackgroundJobPublisherRabbitMQ) import Wire.BlockListStore import Wire.BlockListStore.Cassandra +import Wire.BrigAPIAccess (BrigAPIAccess) +import Wire.BrigAPIAccess.Local (interpretBrigAPIAccessLocally) import Wire.BudgetStore import Wire.BudgetStore.Cassandra import Wire.ClientStore (ClientStore) @@ -130,6 +133,7 @@ import Wire.PropertySubsystem.Interpreter import Wire.RateLimit import Wire.RateLimit.Interpreter import Wire.Rpc +import Wire.RpcException (RpcException) import Wire.SAMLEmailSubsystem import Wire.SAMLEmailSubsystem.Interpreter import Wire.SFT (SFT, interpretSFT) @@ -189,13 +193,12 @@ type RecursiveEffects = '[ AuthenticationSubsystem, UserSubsystem, AppSubsystem, - ClientSubsystem + ClientSubsystem, + BrigAPIAccess, + TeamCollaboratorsSubsystem ] -type NonRecursiveEffects2 = - '[ TeamCollaboratorsSubsystem - ] - `Append` BrigLowerLevelEffects +type NonRecursiveEffects2 = BrigLowerLevelEffects -- | These effects have interpreters which don't depend on each other type BrigLowerLevelEffects = @@ -276,6 +279,7 @@ type BrigLowerLevelEffects = Embed Cas.Client, Error ClientError, Error ParseException, + Error RpcException, Error ErrorCall, Error SomeException, Error HttpError, @@ -297,9 +301,11 @@ type BrigLowerLevelEffects = -- Cloned from "Wire.MiniBackend". runRecursiveEffects :: (Members NonRecursiveEffects2 r) => + -- | Brig's own endpoint; see 'interpretBrigAPIAccessLocally'. + Endpoint -> Sem (RecursiveEffects `Append` r) a -> Sem r a -runRecursiveEffects = runClient . runApp . runUser . runAuth +runRecursiveEffects selfEndpoint = runTeamCollaborators . runBrigAPIAccess . runClient . runApp . runUser . runAuth where runAuth :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor AuthenticationSubsystem r runAuth = interpretAuthenticationSubsystem runUser @@ -313,6 +319,12 @@ runRecursiveEffects = runClient . runApp . runUser . runAuth runClient :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor ClientSubsystem r runClient = runClientSubsystem runAuth runUser + runBrigAPIAccess :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor BrigAPIAccess r + runBrigAPIAccess = interpretBrigAPIAccessLocally selfEndpoint runUser + + runTeamCollaborators :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor TeamCollaboratorsSubsystem r + runTeamCollaborators = interpretTeamCollaboratorsSubsystem runBrigAPIAccess + runBrigToIO :: App.Env -> AppT BrigCanonicalEffects a -> IO a runBrigToIO e (AppT ma) = do let blockedDomains = @@ -433,6 +445,7 @@ runBrigToIO e (AppT ma) = do . rethrowHttpErrorIO . runError @SomeException . mapError @ErrorCall SomeException + . mapError @RpcException SomeException . mapError @ParseException SomeException . mapError clientErrorToHttpError . interpretClientToIO e.casClient @@ -510,8 +523,7 @@ runBrigToIO e (AppT ma) = do . interpretTeamCollaboratorsStoreToPostgres . interpretTeamSubsystemToGalleyAPI . samlEmailSubsystemInterpreter - . interpretTeamCollaboratorsSubsystem - . runRecursiveEffects + . runRecursiveEffects e.brigEndpoint . interpretUserGroupSubsystem . maybe runEnterpriseLoginSubsystemNoConfig diff --git a/services/brig/src/Brig/Index/Eval.hs b/services/brig/src/Brig/Index/Eval.hs index ea72f9aeef5..d703437a3ad 100644 --- a/services/brig/src/Brig/Index/Eval.hs +++ b/services/brig/src/Brig/Index/Eval.hs @@ -51,6 +51,7 @@ import Polysemy.TinyLog (TinyLog) import System.Logger qualified as Log import System.Logger.Class (Logger) import Util.Options +import Wire.API.Team.Collaborator (TeamCollaboratorsError) import Wire.ClientSubsystem.Error (ClientError) import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.GalleyAPIAccess.Rpc @@ -65,6 +66,8 @@ import Wire.Rpc import Wire.Sem.Logger.TinyLog import Wire.Sem.Metrics (Metrics) import Wire.Sem.Metrics.IO +import Wire.TeamCollaboratorsStore (TeamCollaboratorsStore) +import Wire.TeamCollaboratorsStore.Postgres (interpretTeamCollaboratorsStoreToPostgres) import Wire.UserKeyStore (UserKeyStore) import Wire.UserKeyStore.Cassandra import Wire.UserSearch.Migration (MigrationException) @@ -75,6 +78,7 @@ import Wire.UserStore.Postgres (interpretUserStorePostgres) type BrigIndexEffectStack = [ UserKeyStore, UserStore, + TeamCollaboratorsStore, IndexedUserStore, Error IndexedUserStoreError, IndexedUserMigrationStore, @@ -86,6 +90,7 @@ type BrigIndexEffectStack = TinyLog, Input Hasql.Pool, Error UsageError, + Error TeamCollaboratorsError, Error ClientError, Embed IO, Final IO @@ -132,6 +137,7 @@ runSem (mgr, casClient, pgPool, bhEnv, indexedUserStoreConfig, reqId, migrationI runFinal . embedToFinal . throwErrorToIOFinal @ClientError + . throwErrorToIOFinal @TeamCollaboratorsError . throwPostgresUsageErrorToIOFinal . runInputConst pgPool . loggerToTinyLogReqId reqId logger @@ -143,6 +149,7 @@ runSem (mgr, casClient, pgPool, bhEnv, indexedUserStoreConfig, reqId, migrationI . interpretIndexedUserMigrationStoreES bhEnv migrationIndexName . throwErrorToIOFinal @IndexedUserStoreError . interpretIndexedUserStoreES indexedUserStoreConfig + . interpretTeamCollaboratorsStoreToPostgres . userStoreInterpreter . interpretUserKeyStoreCassandra casClient $ action diff --git a/services/brig/src/Brig/User/Search/Index.hs b/services/brig/src/Brig/User/Search/Index.hs index 4c4919729d9..68a17f07b1f 100644 --- a/services/brig/src/Brig/User/Search/Index.hs +++ b/services/brig/src/Brig/User/Search/Index.hs @@ -364,6 +364,15 @@ indexMapping = mpAnalyzer = Nothing, mpFields = mempty }, + -- teams this user collaborates with (without being a member of them) + "collaborating_teams" + .= MappingProperty + { mpType = MPKeyword, + mpStore = False, + mpIndex = True, + mpAnalyzer = Nothing, + mpFields = mempty + }, "accent_id" .= MappingProperty { mpType = MPByte, diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index 4d755d8c612..f47dc7f3798 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -572,7 +572,7 @@ evalGalley e = . interpretTeamSubsystem teamSubsystemConfig . runFeaturesConfigSubsystem . runInputSem getAllTeamFeaturesForServer - . interpretTeamCollaboratorsSubsystem + . interpretTeamCollaboratorsSubsystem (interpretBrigAccess (e ^. brig)) . runFederationSubsystem conversationSubsystemConfig.federationProtocols . runInputConst (e ^. reqId) . interpretJobSubsystem From bc1ae687fcaa063985237a9d44533437da26e49b Mon Sep 17 00:00:00 2001 From: Gautier DI FOLCO Date: Wed, 26 Aug 2026 12:46:15 +0200 Subject: [PATCH 108/113] WPB-28272: Make meeting tzid updatable via PUT /meetings/{domain}/{id} (#5479) --- changelog.d/1-api-changes/WPB-28272 | 2 + integration/test/Test/Meetings.hs | 26 +++++++ libs/wire-api/src/Wire/API/Meeting.hs | 12 ++-- .../wire-subsystems/src/Wire/MeetingsStore.hs | 1 + .../src/Wire/MeetingsStore/Postgres.hs | 35 ++++++---- .../src/Wire/MeetingsSubsystem/Interpreter.hs | 10 +-- .../Wire/MeetingsSubsystem/InterpreterSpec.hs | 69 ++++++++++++++----- .../Wire/MockInterpreters/MeetingsStore.hs | 3 +- 8 files changed, 119 insertions(+), 39 deletions(-) create mode 100644 changelog.d/1-api-changes/WPB-28272 diff --git a/changelog.d/1-api-changes/WPB-28272 b/changelog.d/1-api-changes/WPB-28272 new file mode 100644 index 00000000000..1619e6df99f --- /dev/null +++ b/changelog.d/1-api-changes/WPB-28272 @@ -0,0 +1,2 @@ +`PUT /meetings/{domain}/{id}` now accepts an optional `tzid` field to update a +meeting's IANA time zone. diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs index 779b9aed3c1..252f87c450a 100644 --- a/integration/test/Test/Meetings.hs +++ b/integration/test/Test/Meetings.hs @@ -329,6 +329,32 @@ testMeetingUpdateUnauthorized = do putMeeting otherUser domain meetingId update >>= assertStatus 404 +-- | WPB-28272: PUT /meetings/{domain}/{id} accepts an optional @tzid@ and +-- updates the stored time zone; omitting it (as legacy clients do) keeps the +-- stored value; an invalid tzid is rejected at decode time. +testMeetingUpdateTzid :: (HasCallStack) => App () +testMeetingUpdateTzid = do + (owner, _tid, _members) <- createTeam OwnDomain 1 + now <- liftIO getCurrentTime + let newMeeting = defaultMeetingJson "Tzid Meeting" (addUTCTime 3600 now) (addUTCTime 7200 now) [] + meeting <- postMeetings owner newMeeting >>= getJSON 201 + (meetingId, domain) <- getMeetingIdAndDomain meeting + meeting %. "tzid" `shouldMatch` ("Europe/Berlin" :: String) + + updated <- putMeeting owner domain meetingId (object ["tzid" .= ("America/New_York" :: String)]) >>= getJSON 200 + updated %. "tzid" `shouldMatch` ("America/New_York" :: String) + updated %. "title" `shouldMatch` ("Tzid Meeting" :: String) + + fetched <- getMeeting owner domain meetingId >>= getJSON 200 + fetched %. "tzid" `shouldMatch` ("America/New_York" :: String) + + putMeeting owner domain meetingId (object ["title" .= ("Renamed" :: String)]) >>= assertStatus 200 + fetched2 <- getMeeting owner domain meetingId >>= getJSON 200 + fetched2 %. "tzid" `shouldMatch` ("America/New_York" :: String) + fetched2 %. "title" `shouldMatch` ("Renamed" :: String) + + putMeeting owner domain meetingId (object ["tzid" .= ("not-a-zone" :: String)]) >>= assertLabel 400 "bad-request" + testMeetingListEmpty :: (HasCallStack) => App () testMeetingListEmpty = do (owner, _tid, _members) <- createTeam OwnDomain 1 diff --git a/libs/wire-api/src/Wire/API/Meeting.hs b/libs/wire-api/src/Wire/API/Meeting.hs index 20eaaaa3808..066e8b76563 100644 --- a/libs/wire-api/src/Wire/API/Meeting.hs +++ b/libs/wire-api/src/Wire/API/Meeting.hs @@ -303,15 +303,18 @@ instance ToSchema Frequency where element "yearly" Yearly ] --- | Request to update an existing meeting. Updates carry no @tzid@ (it is --- immutable after creation); @end_time@ is optional on both eras, so a single --- type serves V17 ('UpdateMeeting') and V16 ('UpdateMeetingV16'). +-- | Request to update an existing meeting. @tzid@ is optional: 'Just' sets +-- the meeting's IANA time zone, while omitting it (as legacy V15\/V16 +-- clients, whose request shape carries no @tzid@, always do) leaves the +-- stored time zone unchanged; @end_time@ is optional on both eras, so a +-- single type serves V17 ('UpdateMeeting') and V16 ('UpdateMeetingV16'). data UpdateMeeting = UpdateMeeting { startTime :: Maybe UTCTime, endTime :: Maybe UTCTime, title :: Maybe (Range 1 256 Text), -- | 'Just x' means "set 'recurrence' to 'x', meaning set to a value or unset it" - recurrence :: Maybe (Maybe Recurrence) + recurrence :: Maybe (Maybe Recurrence), + tzid :: Maybe TimeZone } deriving stock (Eq, Show, Generic) deriving (ToJSON, FromJSON, S.ToSchema) via (Schema UpdateMeeting) @@ -327,6 +330,7 @@ instance ToSchema UpdateMeeting where <*> (.endTime) .= maybe_ (optField "end_time" utcTimeSchema) <*> (.title) .= maybe_ (optField "title" schema) <*> (.recurrence) .= fmap Just (maybe_ (maybe_ (optField' "recurrence" schema))) + <*> (.tzid) .= maybe_ (optField "tzid" schema) instance ToSchema Recurrence where schema = diff --git a/libs/wire-subsystems/src/Wire/MeetingsStore.hs b/libs/wire-subsystems/src/Wire/MeetingsStore.hs index 4a4ef8b9476..51179a1d762 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsStore.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsStore.hs @@ -166,6 +166,7 @@ data MeetingsStore m a where Maybe (Range 1 256 Text) -> Maybe UTCTime -> Maybe UTCTime -> + Maybe TimeZone -> Maybe (Maybe Recurrence) -> MeetingsStore m (Maybe StoredMeeting) DeleteMeeting :: diff --git a/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs index 5282c4cddc6..4e191e0f5b9 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs @@ -36,7 +36,7 @@ import Hasql.Statement import Hasql.TH import Imports import Polysemy -import Wire.API.Meeting (Recurrence, TimeZone) +import Wire.API.Meeting (Recurrence, TimeZone, renderTimeZone) import Wire.API.PostgresMarshall (PostgresMarshall (..), PostgresUnmarshall (..), dimapPG) import Wire.API.User.Identity (EmailAddress, fromEmail) import Wire.MeetingsStore @@ -49,8 +49,8 @@ interpretMeetingsStoreToPostgres = interpret $ \case CreateMeeting title creator startTime endTime tzid recurrence convId emails trial -> createMeetingImpl title creator startTime endTime tzid recurrence convId emails trial - UpdateMeeting meetingId title startDate endTime schedule -> - updateMeetingImpl meetingId title startDate endTime schedule + UpdateMeeting meetingId title startDate endTime tzid schedule -> + updateMeetingImpl meetingId title startDate endTime tzid schedule DeleteMeeting meetingId -> deleteMeetingImpl meetingId GetMeeting meetingId -> @@ -132,6 +132,7 @@ type UpdateStoredMeetingWithRecurrenceTuple = ( Maybe Text, -- title Maybe UTCTime, -- start_time Maybe UTCTime, -- end_time + Maybe Text, -- tzid Maybe Text, -- recurrence_frequency Maybe Int32, -- recurrence_interval Maybe UTCTime, -- recurrence_until @@ -142,16 +143,18 @@ type UpdateMeetingWithRecurrenceTuple = ( Maybe (Range 1 256 Text), -- title Maybe UTCTime, -- start_time Maybe UTCTime, -- end_time + Maybe TimeZone, -- tzid Maybe Recurrence, -- recurrence MeetingId -- meeting id ) instance PostgresMarshall UpdateStoredMeetingWithRecurrenceTuple UpdateMeetingWithRecurrenceTuple where - postgresMarshall (mTitle, mStartTime, mEndTime, recurrence, id') = + postgresMarshall (mTitle, mStartTime, mEndTime, mTzid, recurrence, id') = let (rFreq, rInterval, rUntil) = postgresMarshall recurrence in ( fromRange <$> mTitle, mStartTime, mEndTime, + renderTimeZone <$> mTzid, rFreq, rInterval, rUntil, @@ -162,6 +165,7 @@ type UpdateStoredMeetingWithoutRecurrenceTuple = ( Maybe Text, -- title Maybe UTCTime, -- start_time Maybe UTCTime, -- end_time + Maybe Text, -- tzid UUID -- meeting id ) @@ -169,14 +173,16 @@ type UpdateMeetingWithoutRecurrenceTuple = ( Maybe (Range 1 256 Text), -- title Maybe UTCTime, -- start_time Maybe UTCTime, -- end_time + Maybe TimeZone, -- tzid MeetingId -- meeting id ) instance {-# OVERLAPPING #-} PostgresMarshall UpdateStoredMeetingWithoutRecurrenceTuple UpdateMeetingWithoutRecurrenceTuple where - postgresMarshall (mTitle, mStartTime, mEndTime, id') = + postgresMarshall (mTitle, mStartTime, mEndTime, mTzid, id') = ( fromRange <$> mTitle, mStartTime, mEndTime, + renderTimeZone <$> mTzid, toUUID id' ) @@ -186,14 +192,15 @@ updateMeetingImpl :: Maybe (Range 1 256 Text) -> Maybe UTCTime -> Maybe UTCTime -> + Maybe TimeZone -> Maybe (Maybe Recurrence) -> Sem r (Maybe StoredMeeting) -updateMeetingImpl meetingId mTitle mStartDate mEndTime mRecurrence = do +updateMeetingImpl meetingId mTitle mStartDate mEndTime mTzid mRecurrence = do case mRecurrence of Nothing -> - runStatement (mTitle, mStartDate, mEndTime, meetingId) updateWithoutRecurrenceStatement + runStatement (mTitle, mStartDate, mEndTime, mTzid, meetingId) updateWithoutRecurrenceStatement Just recurrence -> - runStatement (mTitle, mStartDate, mEndTime, recurrence, meetingId) updateWithRecurrenceStatement + runStatement (mTitle, mStartDate, mEndTime, mTzid, recurrence, meetingId) updateWithRecurrenceStatement where updateWithRecurrenceStatement :: Statement UpdateMeetingWithRecurrenceTuple (Maybe StoredMeeting) updateWithRecurrenceStatement = @@ -207,11 +214,12 @@ updateMeetingImpl meetingId mTitle mStartDate mEndTime mRecurrence = do SET title = COALESCE($1 :: text?, title), start_time = COALESCE($2 :: timestamptz?, start_time), end_time = COALESCE($3 :: timestamptz?, end_time), - recurrence_frequency = $4 :: text? :: recurrence_frequency, - recurrence_interval = $5 :: int4?, - recurrence_until = $6 :: timestamptz?, + tzid = COALESCE($4 :: text?, tzid), + recurrence_frequency = $5 :: text? :: recurrence_frequency, + recurrence_interval = $6 :: int4?, + recurrence_until = $7 :: timestamptz?, updated_at = NOW() - WHERE id = ($7 :: uuid) + WHERE id = ($8 :: uuid) RETURNING id :: uuid, title :: text, creator :: uuid, start_time :: timestamptz, end_time :: timestamptz, tzid :: text, @@ -232,8 +240,9 @@ updateMeetingImpl meetingId mTitle mStartDate mEndTime mRecurrence = do SET title = COALESCE($1 :: text?, title), start_time = COALESCE($2 :: timestamptz?, start_time), end_time = COALESCE($3 :: timestamptz?, end_time), + tzid = COALESCE($4 :: text?, tzid), updated_at = NOW() - WHERE id = ($4 :: uuid) + WHERE id = ($5 :: uuid) RETURNING id :: uuid, title :: text, creator :: uuid, start_time :: timestamptz, end_time :: timestamptz, tzid :: text, diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs index 88e764b9f63..a5fdc5a57cc 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs @@ -247,7 +247,7 @@ updateMeetingImpl :: updateMeetingImpl zUser connId meetingId update validityPeriod pastEditPeriod = do maybeTeamId <- TeamSubsystem.internalGetOneUserTeam (tUnqualified zUser) checkMeetingsEnabled maybeTeamId - when (isNothing update.title && isNothing update.startTime && isNothing update.endTime && isNothing update.recurrence) $ + when (isNothing update.title && isNothing update.startTime && isNothing update.endTime && isNothing update.recurrence && isNothing update.tzid) $ throw EmptyUpdate runMaybeT $ do @@ -278,15 +278,17 @@ updateMeetingImpl zUser connId meetingId update validityPeriod pastEditPeriod = update.title update.startTime update.endTime + update.tzid update.recurrence conv <- MaybeT $ getMeetingConversationOrFail meetingId updatedMeeting.conversationId lift $ notifyMeetingEvent zUser (Just connId) conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId MeetingEvent.Update meetingId pure $ storedMeetingToMeetingWithConversation zUser conv updatedMeeting --- | V16 update path: 'API.UpdateMeetingV16' is now 'API.UpdateMeeting' (both +-- | V16 update path: 'API.UpdateMeetingV16' is 'API.UpdateMeeting' (both -- carry an optional @end_time@), so this delegates straight through to the --- shared update implementation and re-shapes the result. @tzid@ is immutable, --- so the legacy time zone is not needed here. +-- shared update implementation and re-shapes the result. The legacy request +-- shape carries no @tzid@ from V15\/V16 clients, and an omitted @tzid@ leaves +-- the stored time zone unchanged, so no legacy time zone injection is needed. updateMeetingV16Impl :: ( Member Store.MeetingsStore r, Member ConversationSubsystem r, diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index ecb729b5b89..145bf63ba8b 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -400,10 +400,35 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do result <- runTestStack now gen Map.empty teamConfig $ do meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting - updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing Nothing Nothing) + updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing Nothing Nothing Nothing) result `shouldBe` Left EmptyUpdate + it "updates tzid when provided alone" $ do + let newMeeting = + API.NewMeeting + { title = fromJust $ checked "Tzid Meeting", + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, + recurrence = Nothing, + invitedEmails = [] + } + newTz = fromJust (API.parseTimeZone "Europe/London") + result <- runTestStack now gen Map.empty teamConfig $ do + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + updateMeeting + zUser1 + (ConnId "test-conn") + meeting.meeting.id + (API.UpdateMeeting Nothing Nothing Nothing Nothing (Just newTz)) + case result of + Left err -> fail $ "Expected the update to be applied, got: " <> show err + Right Nothing -> fail "Expected the update to be applied" + Right (Just updated) -> do + updated.meeting.tzid `shouldBe` newTz + updated.meeting.title `shouldBe` newMeeting.title + it "throws InvalidTimes when end_time is not after start_time" $ do let newMeeting = API.NewMeeting @@ -422,7 +447,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { startTime = Nothing, endTime = Just (addUTCTime 3600 now), title = Nothing, - recurrence = Nothing + recurrence = Nothing, + tzid = Nothing } updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update result `shouldBe` Left (InvalidTimes EndBeforeStart) @@ -445,7 +471,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { startTime = Just (addUTCTime (negate 60) now), endTime = Nothing, title = Nothing, - recurrence = Nothing + recurrence = Nothing, + tzid = Nothing } updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update @@ -473,7 +500,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { startTime = Just (addUTCTime (negate configuredPastEditPeriod) now), endTime = Nothing, title = Nothing, - recurrence = Nothing + recurrence = Nothing, + tzid = Nothing } updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update @@ -499,7 +527,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { startTime = Just (addUTCTime (negate (configuredPastEditPeriod + 1)) now), endTime = Nothing, title = Nothing, - recurrence = Nothing + recurrence = Nothing, + tzid = Nothing } updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update result `shouldBe` Left (InvalidTimes TimesBeyondPastEditWindow) @@ -522,7 +551,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { startTime = Just (addUTCTime (negate 7200) now), endTime = Just (addUTCTime (negate (configuredPastEditPeriod + 1)) now), title = Nothing, - recurrence = Nothing + recurrence = Nothing, + tzid = Nothing } updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update result `shouldBe` Left (InvalidTimes TimesBeyondPastEditWindow) @@ -551,7 +581,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do startTime = Just (addUTCTime 100 now), endTime = Nothing, title = Just (unsafeRange "Edited While Ongoing"), - recurrence = Nothing + recurrence = Nothing, + tzid = Nothing } updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update case result of @@ -580,7 +611,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { startTime = Just (addUTCTime 8000 now), endTime = Nothing, title = Nothing, - recurrence = Nothing + recurrence = Nothing, + tzid = Nothing } updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update result `shouldBe` Left (InvalidTimes EndBeforeStart) @@ -599,7 +631,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do result <- runTestStack now gen Map.empty teamConfig $ do meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting passTime validityWindow - updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing) + updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing Nothing) result `shouldBe` Right Nothing @@ -616,7 +648,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting - updateMeeting zUser2 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing) + updateMeeting zUser2 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing Nothing) result `shouldBe` Right Nothing @@ -635,7 +667,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting -- Simulate a data-inconsistency: the meeting's conversation vanished. modify @(Map ConvId StoredConversation) (Map.delete (qUnqualified meeting.meeting.conversationId)) - updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) + updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing Nothing) result `shouldBe` Right Nothing @@ -659,9 +691,10 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do (fmap (max (addUTCTime (negate 60) now)) update.endTime) update.title update.recurrence + update.tzid effectiveStart = fromMaybe baseMeeting.startTime sanitizedUpdate.startTime effectiveEndTime = fromMaybe baseMeeting.endTime sanitizedUpdate.endTime - isNotEmpty = sanitizedUpdate /= API.UpdateMeeting Nothing Nothing Nothing Nothing + isNotEmpty = sanitizedUpdate /= API.UpdateMeeting Nothing Nothing Nothing Nothing Nothing hasValidTimes = effectiveEndTime > effectiveStart in isNotEmpty && hasValidTimes ==> ioProperty $ do @@ -680,6 +713,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do .&&. m.meeting.startTime === effectiveStart .&&. m.meeting.endTime === effectiveEndTime .&&. m.meeting.recurrence === fromMaybe baseMeeting.recurrence sanitizedUpdate.recurrence + .&&. m.meeting.tzid === fromMaybe baseMeeting.tzid sanitizedUpdate.tzid .&&. m.meeting.conversationId === convId describe "deleteMeeting" $ do @@ -1218,7 +1252,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do runTestStack now gen Map.empty teamConfig $ do meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence) passTime validityWindow - updateMeeting zUser (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) + updateMeeting zUser (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing Nothing) fmap isJust result `shouldBe` Right True it "addInvitedEmails succeeds on a recurring meeting whose slot passed" $ do @@ -1419,7 +1453,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do Right meeting -> do result2 <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ - updateMeeting zUserTeam (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) + updateMeeting zUserTeam (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing Nothing) result2 `shouldBe` Left MeetingsFeatureDisabled @@ -1528,7 +1562,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting put @[Push] [] - _ <- updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) + _ <- updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing Nothing) get @[Push] case result of @@ -1558,7 +1592,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting put @[Push] [] - _ <- updateMeeting zUser2 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Hijack")) Nothing) + _ <- updateMeeting zUser2 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Hijack")) Nothing Nothing) get @[Push] case result of @@ -1666,7 +1700,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { startTime = Just newStart, endTime = Just newEnd, title = Nothing, - recurrence = Nothing + recurrence = Nothing, + tzid = Nothing } result <- runTestStack now gen Map.empty def $ do diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs index 6f4a358b4a3..c54f2de3b3f 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs @@ -54,7 +54,7 @@ inMemoryMeetingsStoreInterpreter = interpret $ \case modify (Map.insert mid sm) pure sm GetMeeting mid -> gets (Map.lookup mid) - UpdateMeeting mid title startTime endTime recurrence -> do + UpdateMeeting mid title startTime endTime tzid recurrence -> do sm <- gets (Map.lookup mid) case sm of Nothing -> pure Nothing @@ -65,6 +65,7 @@ inMemoryMeetingsStoreInterpreter = interpret $ \case { title = fromMaybe (meeting.title) title, startTime = startTime', endTime = fromMaybe meeting.endTime endTime, + tzid = fromMaybe meeting.tzid tzid, recurrence = fromMaybe meeting.recurrence recurrence, updatedAt = now } From 11f4dd99737a0fd04cb172e4c8e2a4bbaa6755df Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 26 Aug 2026 15:11:41 +0200 Subject: [PATCH 109/113] [WPB-28280] Automate license header updates in treefmt. (#5481) * Changelog. * Clean up headroom setup. - Re-add headroom to treefmt; - make old license headers static; - make new license headers reference _current_year, not 2025. * Run treefmt. * Remove (newly) deprecated add-license rule from Makefile. NB: legally it is not important that the year or year span in the license header has any relation to the file age, but otherwise the header needs to be present and intact. --- .headroom.yaml | 9 ++++++-- Makefile | 11 +--------- ...automate-license-header-updates-in-treefmt | 1 + hack/bin/headroom-treefmt.sh | 21 +++++++++++++++++++ integration/test/Test/Meetings.hs | 17 +++++++++++++++ .../test/Test/Migration/ConversationCodes.hs | 17 +++++++++++++++ .../test/Test/Migration/DomainRegistration.hs | 17 +++++++++++++++ .../test/Test/Migration/TeamFeatures.hs | 17 +++++++++++++++ integration/test/Test/Migration/Util.hs | 17 +++++++++++++++ .../Test/Spar/CertFingerprintAllowlist.hs | 17 +++++++++++++++ integration/test/Test/Spar/MultiIngressIdp.hs | 17 +++++++++++++++ libs/extended/src/Data/Hourglass/Const.hs | 17 +++++++++++++++ libs/extended/src/Data/X509/Extended.hs | 17 +++++++++++++++ .../test/Test/Data/Hourglass/ConstSpec.hs | 17 +++++++++++++++ .../test/Test/Data/X509/ExtendedSpec.hs | 17 +++++++++++++++ .../src/Network/Wai/Utilities/Exception.hs | 17 +++++++++++++++ libs/wire-api/src/Wire/API/Event/Meeting.hs | 17 +++++++++++++++ .../src/Wire/API/Routes/Internal/Galley.hs | 17 +++++++++++++++ .../Wire/API/Routes/Public/Galley/Feature.hs | 17 +++++++++++++++ .../golden/Test/Wire/API/Golden/Manual/IdP.hs | 17 +++++++++++++++ .../test/unit/Test/Wire/API/Meeting.hs | 17 +++++++++++++++ .../Wire/API/Roundtrip/PostgresMarshall.hs | 17 +++++++++++++++ libs/wire-subsystems/src/Wire/AWS.hs | 17 +++++++++++++++ libs/wire-subsystems/src/Wire/ClientStore.hs | 17 +++++++++++++++ .../src/Wire/ClientStore/Cassandra.hs | 17 +++++++++++++++ .../src/Wire/ClientSubsystem.hs | 17 +++++++++++++++ .../src/Wire/ClientSubsystem/Error.hs | 17 +++++++++++++++ .../src/Wire/ClientSubsystem/Interpreter.hs | 17 +++++++++++++++ .../FeaturesConfigSubsystem/Interpreter.hs | 17 +++++++++++++++ .../src/Wire/FeaturesConfigSubsystem/Types.hs | 17 +++++++++++++++ .../src/Wire/FeaturesConfigSubsystem/Utils.hs | 17 +++++++++++++++ .../src/Wire/IdPConfigStore/Orphans.hs | 17 +++++++++++++++ libs/wire-subsystems/src/Wire/IdPSubsystem.hs | 17 +++++++++++++++ .../src/Wire/IdPSubsystem/Interpreter.hs | 17 +++++++++++++++ libs/wire-subsystems/src/Wire/LegalHold.hs | 17 +++++++++++++++ .../src/Wire/LegalHoldStore.hs | 17 +++++++++++++++ .../src/Wire/LegalHoldStore/Cassandra.hs | 17 +++++++++++++++ .../Wire/LegalHoldStore/Cassandra/Queries.hs | 17 +++++++++++++++ .../src/Wire/LegalHoldStore/Env.hs | 17 +++++++++++++++ .../wire-subsystems/src/Wire/MigrationLock.hs | 17 +++++++++++++++ .../src/Wire/SAMLEmailSubsystem.hs | 17 +++++++++++++++ .../Wire/SAMLEmailSubsystem/Interpreter.hs | 17 +++++++++++++++ .../src/Wire/UserStore/Postgres.hs | 17 +++++++++++++++ .../Wire/ClientSubsystem/InterpreterSpec.hs | 17 +++++++++++++++ .../AuthenticationSubsystem.hs | 17 +++++++++++++++ .../unit/Wire/MockInterpreters/ClientStore.hs | 17 +++++++++++++++ .../SAMLEmailSubsystem/InterpreterSpec.hs | 17 +++++++++++++++ nix/wire-server.nix | 2 +- services/spar/test/Test/Spar/Saml/IdPSpec.hs | 17 +++++++++++++++ tools/stern/src/Stern/API/Routes.hs | 17 +++++++++++++++ treefmt.toml | 8 +++++++ 51 files changed, 804 insertions(+), 13 deletions(-) create mode 100644 changelog.d/5-internal/WPB-28280-automate-license-header-updates-in-treefmt create mode 100755 hack/bin/headroom-treefmt.sh diff --git a/.headroom.yaml b/.headroom.yaml index 32a2f4860e8..f1f0ea36cfd 100644 --- a/.headroom.yaml +++ b/.headroom.yaml @@ -1,5 +1,8 @@ version: 0.4.0.0 -run-mode: replace +# 'add' only touches files that have no header at all. Existing headers +# (and, importantly, the years in them) are left alone; see +# https://github.com/wireapp/wire-server/pull/4851. +run-mode: add source-paths: - libs - services @@ -13,7 +16,9 @@ variables: organization: Wire Swiss GmbH email: opensource@wire.com project: This file is part of the Wire Server implementation. - year: "2025" + # only ever used for files that get a header added now, so this needs no + # maintenance and causes no churn in files that already have one. + year: "{{ _current_year }}" license-headers: haskell: file-extensions: ["hs", "hsc"] diff --git a/Makefile b/Makefile index ccce920f5fe..2a3250275e6 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ calling-test demo-smtp elasticsearch-curator elasticsearch-external \ elasticsearch-ephemeral minio-external cassandra-external \ ingress-nginx-controller nginx-ingress-services reaper \ k8ssandra-test-cluster ldap-scim-bridge wire-server-enterprise \ -wire-ingress +wire-ingress KIND_CLUSTER_NAME := wire-server HELM_PARALLELISM ?= 1 # 1 for sequential tests; 6 for all-parallel tests PSQL_DB ?= backendA @@ -276,15 +276,6 @@ formatf-all: formatc: ./tools/ormolu.sh -c -# For any Haskell or Rust file, update or add a license header if necessary. -# Headers should be added according to Ormolu's formatting rules, but please check just in case. -.PHONY: add-license -add-license: - command -v headroom - headroom run -a - @echo "" - @echo "you might want to run 'make formatf' now to make sure ormolu is happy" - # without redirecting stdin/-out/-err, emacs does something weird that takes 3-5 seconds. .PHONY: treefmt treefmt: diff --git a/changelog.d/5-internal/WPB-28280-automate-license-header-updates-in-treefmt b/changelog.d/5-internal/WPB-28280-automate-license-header-updates-in-treefmt new file mode 100644 index 00000000000..67c229811fa --- /dev/null +++ b/changelog.d/5-internal/WPB-28280-automate-license-header-updates-in-treefmt @@ -0,0 +1 @@ +Automate license header updates in treefmt. diff --git a/hack/bin/headroom-treefmt.sh b/hack/bin/headroom-treefmt.sh new file mode 100755 index 00000000000..6f31cf343f5 --- /dev/null +++ b/hack/bin/headroom-treefmt.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +# treefmt adapter for headroom (see treefmt.toml and .headroom.yaml). +# +# treefmt hands us a list of file paths, headroom wants them as repeated +# '-s' arguments. Everything else (templates, variables, run-mode) comes +# from .headroom.yaml, which headroom picks up from the working directory +# (treefmt runs formatters from the root of the tree). + +set -euo pipefail + +[[ $# -eq 0 ]] && exit 0 + +args=() +for file in "$@"; do + args+=(-s "$file") +done + +# '-a' matches 'run-mode: add' from the config; we pass it explicitly so that +# this stays add-only even if someone changes the config's default run-mode. +headroom run -a "${args[@]}" diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs index 252f87c450a..8c3f231bd6e 100644 --- a/integration/test/Test/Meetings.hs +++ b/integration/test/Test/Meetings.hs @@ -1,5 +1,22 @@ {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Meetings where import API.Galley diff --git a/integration/test/Test/Migration/ConversationCodes.hs b/integration/test/Test/Migration/ConversationCodes.hs index 81d7ad8b132..5ced8701551 100644 --- a/integration/test/Test/Migration/ConversationCodes.hs +++ b/integration/test/Test/Migration/ConversationCodes.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Migration.ConversationCodes where import API.Galley diff --git a/integration/test/Test/Migration/DomainRegistration.hs b/integration/test/Test/Migration/DomainRegistration.hs index 3a7915b3242..4d8a4b4a8c0 100644 --- a/integration/test/Test/Migration/DomainRegistration.hs +++ b/integration/test/Test/Migration/DomainRegistration.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Migration.DomainRegistration (testDomainRegistrationMigration) where import qualified API.Brig as Brig diff --git a/integration/test/Test/Migration/TeamFeatures.hs b/integration/test/Test/Migration/TeamFeatures.hs index 31adaae39b6..33b091a5a4f 100644 --- a/integration/test/Test/Migration/TeamFeatures.hs +++ b/integration/test/Test/Migration/TeamFeatures.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Migration.TeamFeatures where import qualified API.Galley as Public diff --git a/integration/test/Test/Migration/Util.hs b/integration/test/Test/Migration/Util.hs index f55db0c58f9..ba3a116b453 100644 --- a/integration/test/Test/Migration/Util.hs +++ b/integration/test/Test/Migration/Util.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Migration.Util where import Control.Applicative diff --git a/integration/test/Test/Spar/CertFingerprintAllowlist.hs b/integration/test/Test/Spar/CertFingerprintAllowlist.hs index b88d47b69a1..e6494f52930 100644 --- a/integration/test/Test/Spar/CertFingerprintAllowlist.hs +++ b/integration/test/Test/Spar/CertFingerprintAllowlist.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Spar.CertFingerprintAllowlist where import API.GalleyInternal (setTeamFeatureStatus) diff --git a/integration/test/Test/Spar/MultiIngressIdp.hs b/integration/test/Test/Spar/MultiIngressIdp.hs index 88c1a8853c8..9a490e023a3 100644 --- a/integration/test/Test/Spar/MultiIngressIdp.hs +++ b/integration/test/Test/Spar/MultiIngressIdp.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Spar.MultiIngressIdp where import API.GalleyInternal diff --git a/libs/extended/src/Data/Hourglass/Const.hs b/libs/extended/src/Data/Hourglass/Const.hs index 0e2f9b796a5..eef02d8fe23 100644 --- a/libs/extended/src/Data/Hourglass/Const.hs +++ b/libs/extended/src/Data/Hourglass/Const.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Data.Hourglass.Const (midnight) where import Data.Hourglass diff --git a/libs/extended/src/Data/X509/Extended.hs b/libs/extended/src/Data/X509/Extended.hs index 81c40888a8e..794e6123631 100644 --- a/libs/extended/src/Data/X509/Extended.hs +++ b/libs/extended/src/Data/X509/Extended.hs @@ -1,5 +1,22 @@ {-# LANGUAGE RecordWildCards #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Data.X509.Extended ( certToString, certDescription, diff --git a/libs/extended/test/Test/Data/Hourglass/ConstSpec.hs b/libs/extended/test/Test/Data/Hourglass/ConstSpec.hs index 4b17ad3e597..67028163553 100644 --- a/libs/extended/test/Test/Data/Hourglass/ConstSpec.hs +++ b/libs/extended/test/Test/Data/Hourglass/ConstSpec.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Data.Hourglass.ConstSpec where import Data.Hourglass diff --git a/libs/extended/test/Test/Data/X509/ExtendedSpec.hs b/libs/extended/test/Test/Data/X509/ExtendedSpec.hs index abe4ad2cd06..d1fce5ba2a9 100644 --- a/libs/extended/test/Test/Data/X509/ExtendedSpec.hs +++ b/libs/extended/test/Test/Data/X509/ExtendedSpec.hs @@ -1,5 +1,22 @@ {-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Data.X509.ExtendedSpec where import Crypto.Hash.Algorithms (SHA256 (SHA256)) diff --git a/libs/wai-utilities/src/Network/Wai/Utilities/Exception.hs b/libs/wai-utilities/src/Network/Wai/Utilities/Exception.hs index 45f43e1cefe..fa30cb1e3bd 100644 --- a/libs/wai-utilities/src/Network/Wai/Utilities/Exception.hs +++ b/libs/wai-utilities/src/Network/Wai/Utilities/Exception.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Network.Wai.Utilities.Exception where import Control.Exception diff --git a/libs/wire-api/src/Wire/API/Event/Meeting.hs b/libs/wire-api/src/Wire/API/Event/Meeting.hs index 90fd6b25c1e..926059d2989 100644 --- a/libs/wire-api/src/Wire/API/Event/Meeting.hs +++ b/libs/wire-api/src/Wire/API/Event/Meeting.hs @@ -16,6 +16,23 @@ -- with this program. If not, see . {-# LANGUAGE StrictData #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.API.Event.Meeting ( -- * Event Event (..), diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs index 793e66d23d7..669b0f1fe7a 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs @@ -16,6 +16,23 @@ -- with this program. If not, see . {-# OPTIONS_GHC -Wno-deprecations #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.API.Routes.Internal.Galley where import Control.Lens ((.~)) diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs index ea18bc9a151..923ecc7d4a5 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs @@ -16,6 +16,23 @@ -- with this program. If not, see . {-# OPTIONS_GHC -Wno-deprecations #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.API.Routes.Public.Galley.Feature where import Data.Id diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/IdP.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/IdP.hs index 43f4a190f31..b2f7b3db6ef 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/IdP.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/IdP.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Wire.API.Golden.Manual.IdP where import Data.Domain (Domain (..)) diff --git a/libs/wire-api/test/unit/Test/Wire/API/Meeting.hs b/libs/wire-api/test/unit/Test/Wire/API/Meeting.hs index a6cfc35f9d5..63a929f2bf4 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Meeting.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Meeting.hs @@ -16,6 +16,23 @@ -- with this program. If not, see . {-# LANGUAGE OverloadedRecordDot #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Wire.API.Meeting where import Test.Tasty diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/PostgresMarshall.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/PostgresMarshall.hs index 191f55bffd0..532ff0510ed 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/PostgresMarshall.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/PostgresMarshall.hs @@ -16,6 +16,23 @@ -- with this program. If not, see . {-# OPTIONS_GHC -Wno-orphans #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Wire.API.Roundtrip.PostgresMarshall (tests) where import Crypto.Error (CryptoFailable (..)) diff --git a/libs/wire-subsystems/src/Wire/AWS.hs b/libs/wire-subsystems/src/Wire/AWS.hs index 78b7e74eff6..349145ab7e5 100644 --- a/libs/wire-subsystems/src/Wire/AWS.hs +++ b/libs/wire-subsystems/src/Wire/AWS.hs @@ -17,6 +17,23 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE TemplateHaskell #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.AWS where import Amazonka qualified as AWS diff --git a/libs/wire-subsystems/src/Wire/ClientStore.hs b/libs/wire-subsystems/src/Wire/ClientStore.hs index 844ecb068d9..3eda15bebda 100644 --- a/libs/wire-subsystems/src/Wire/ClientStore.hs +++ b/libs/wire-subsystems/src/Wire/ClientStore.hs @@ -1,5 +1,22 @@ {-# LANGUAGE TemplateHaskell #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.ClientStore where import Data.Id diff --git a/libs/wire-subsystems/src/Wire/ClientStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ClientStore/Cassandra.hs index 6e962abb96a..c09f908a697 100644 --- a/libs/wire-subsystems/src/Wire/ClientStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/ClientStore/Cassandra.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.ClientStore.Cassandra ( ClientStoreCassandraEnv (..), interpretClientStoreCassandra, diff --git a/libs/wire-subsystems/src/Wire/ClientSubsystem.hs b/libs/wire-subsystems/src/Wire/ClientSubsystem.hs index 236eccdb016..de48a98a862 100644 --- a/libs/wire-subsystems/src/Wire/ClientSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/ClientSubsystem.hs @@ -1,5 +1,22 @@ {-# LANGUAGE TemplateHaskell #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.ClientSubsystem where import Data.Default diff --git a/libs/wire-subsystems/src/Wire/ClientSubsystem/Error.hs b/libs/wire-subsystems/src/Wire/ClientSubsystem/Error.hs index 8c9d24f31ed..a7c77ae7490 100644 --- a/libs/wire-subsystems/src/Wire/ClientSubsystem/Error.hs +++ b/libs/wire-subsystems/src/Wire/ClientSubsystem/Error.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.ClientSubsystem.Error where import Data.Id diff --git a/libs/wire-subsystems/src/Wire/ClientSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/ClientSubsystem/Interpreter.hs index 79ea965c8f6..7aba247a75e 100644 --- a/libs/wire-subsystems/src/Wire/ClientSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/ClientSubsystem/Interpreter.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.ClientSubsystem.Interpreter ( runClientSubsystem, ClientError (..), diff --git a/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Interpreter.hs index 70936a806e0..9d466d46b67 100644 --- a/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Interpreter.hs @@ -3,6 +3,23 @@ {-# LANGUAGE TypeOperators #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.FeaturesConfigSubsystem.Interpreter where import Control.Error (hush) diff --git a/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Types.hs b/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Types.hs index 1a4f4113c82..f06b563bff1 100644 --- a/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Types.hs +++ b/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Types.hs @@ -3,6 +3,23 @@ {-# LANGUAGE UndecidableSuperClasses #-} {-# OPTIONS_GHC -Wno-deprecations #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.FeaturesConfigSubsystem.Types where import Data.Default diff --git a/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Utils.hs b/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Utils.hs index 9314fda02a2..ccf990522a0 100644 --- a/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Utils.hs +++ b/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Utils.hs @@ -1,5 +1,22 @@ {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.FeaturesConfigSubsystem.Utils where import Imports diff --git a/libs/wire-subsystems/src/Wire/IdPConfigStore/Orphans.hs b/libs/wire-subsystems/src/Wire/IdPConfigStore/Orphans.hs index 34065d6d85e..31208b8e9ae 100644 --- a/libs/wire-subsystems/src/Wire/IdPConfigStore/Orphans.hs +++ b/libs/wire-subsystems/src/Wire/IdPConfigStore/Orphans.hs @@ -1,5 +1,22 @@ {-# OPTIONS_GHC -fno-warn-orphans #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.IdPConfigStore.Orphans where import Cassandra as Cas diff --git a/libs/wire-subsystems/src/Wire/IdPSubsystem.hs b/libs/wire-subsystems/src/Wire/IdPSubsystem.hs index c3261c63900..2d79b707cd3 100644 --- a/libs/wire-subsystems/src/Wire/IdPSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/IdPSubsystem.hs @@ -1,5 +1,22 @@ {-# LANGUAGE TemplateHaskell #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.IdPSubsystem where import Imports diff --git a/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs index 2af0da60dcf..cb8b0a68921 100644 --- a/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.IdPSubsystem.Interpreter ( interpretIdPSubsystem, IdPSubsystemError (..), diff --git a/libs/wire-subsystems/src/Wire/LegalHold.hs b/libs/wire-subsystems/src/Wire/LegalHold.hs index bd9c3e61495..f6f7e05b84e 100644 --- a/libs/wire-subsystems/src/Wire/LegalHold.hs +++ b/libs/wire-subsystems/src/Wire/LegalHold.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.LegalHold where import Data.Default (def) diff --git a/libs/wire-subsystems/src/Wire/LegalHoldStore.hs b/libs/wire-subsystems/src/Wire/LegalHoldStore.hs index 8bd32acf93e..c590927792c 100644 --- a/libs/wire-subsystems/src/Wire/LegalHoldStore.hs +++ b/libs/wire-subsystems/src/Wire/LegalHoldStore.hs @@ -1,5 +1,22 @@ {-# LANGUAGE TemplateHaskell #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.LegalHoldStore where import Data.ByteString.Lazy.Char8 qualified as LC8 diff --git a/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra.hs index c767c64883b..186e9da8c6c 100644 --- a/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.LegalHoldStore.Cassandra (interpretLegalHoldStoreToCassandra, validateServiceKey) where import Cassandra diff --git a/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra/Queries.hs b/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra/Queries.hs index 97c072a8903..c294b8ddd2d 100644 --- a/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra/Queries.hs +++ b/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra/Queries.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.LegalHoldStore.Cassandra.Queries where import Cassandra as C diff --git a/libs/wire-subsystems/src/Wire/LegalHoldStore/Env.hs b/libs/wire-subsystems/src/Wire/LegalHoldStore/Env.hs index 17926ebce6c..1c629c723db 100644 --- a/libs/wire-subsystems/src/Wire/LegalHoldStore/Env.hs +++ b/libs/wire-subsystems/src/Wire/LegalHoldStore/Env.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.LegalHoldStore.Env where import Data.ByteString.Lazy.Char8 qualified as LC8 diff --git a/libs/wire-subsystems/src/Wire/MigrationLock.hs b/libs/wire-subsystems/src/Wire/MigrationLock.hs index 3fd75598280..8c97876170f 100644 --- a/libs/wire-subsystems/src/Wire/MigrationLock.hs +++ b/libs/wire-subsystems/src/Wire/MigrationLock.hs @@ -17,6 +17,23 @@ {-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE TypeApplications #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.MigrationLock where import Data.Bits diff --git a/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem.hs b/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem.hs index 7204b12ccfa..243668fd312 100644 --- a/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem.hs @@ -1,5 +1,22 @@ {-# LANGUAGE TemplateHaskell #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.SAMLEmailSubsystem where import Polysemy diff --git a/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem/Interpreter.hs index 453406eea3d..49321aaed0e 100644 --- a/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem/Interpreter.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.SAMLEmailSubsystem.Interpreter ( samlEmailSubsystemInterpreter, ) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 76959781bbb..88177b95cde 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -1,6 +1,23 @@ {-# LANGUAGE RecordWildCards #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.UserStore.Postgres (interpretUserStorePostgres) where import Cassandra (GeneralPaginationState (PaginationStatePostgres), PageWithState (..), paginationStatePostgres) diff --git a/libs/wire-subsystems/test/unit/Wire/ClientSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ClientSubsystem/InterpreterSpec.hs index 769dc9d82bf..43493684895 100644 --- a/libs/wire-subsystems/test/unit/Wire/ClientSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ClientSubsystem/InterpreterSpec.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.ClientSubsystem.InterpreterSpec (spec) where import Data.Aeson qualified as A diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/AuthenticationSubsystem.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/AuthenticationSubsystem.hs index 1873f17a118..dc87708e24a 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/AuthenticationSubsystem.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/AuthenticationSubsystem.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.MockInterpreters.AuthenticationSubsystem where import Imports diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ClientStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ClientStore.hs index 5ae23b22dde..c31f18e3976 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ClientStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ClientStore.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.MockInterpreters.ClientStore where import Data.ByteString.Lazy qualified as LBS diff --git a/libs/wire-subsystems/test/unit/Wire/SAMLEmailSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/SAMLEmailSubsystem/InterpreterSpec.hs index e44e517bbfc..5d8363dad4f 100644 --- a/libs/wire-subsystems/test/unit/Wire/SAMLEmailSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/SAMLEmailSubsystem/InterpreterSpec.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.SAMLEmailSubsystem.InterpreterSpec (spec) where import Data.Default diff --git a/nix/wire-server.nix b/nix/wire-server.nix index d6b914fa5f1..c3a3010efe6 100644 --- a/nix/wire-server.nix +++ b/nix/wire-server.nix @@ -470,6 +470,7 @@ let pkgs.cfssl pkgs.awscli2 (hlib.justStaticExecutables pkgs.haskellPackages.cabal-fmt) + (hlib.justStaticExecutables pkgs.haskellPackages.headroom) (hlib.justStaticExecutables pkgs.haskellPackages.weeder) ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.skopeo @@ -553,7 +554,6 @@ let pkgs.nix-prefetch-git pkgs.haskellPackages.cabal-plan pkgs.lsof - pkgs.haskellPackages.headroom profileEnv ] ++ ghcWithPackages diff --git a/services/spar/test/Test/Spar/Saml/IdPSpec.hs b/services/spar/test/Test/Spar/Saml/IdPSpec.hs index dbd72083d34..87892f11f77 100644 --- a/services/spar/test/Test/Spar/Saml/IdPSpec.hs +++ b/services/spar/test/Test/Spar/Saml/IdPSpec.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Spar.Saml.IdPSpec where import Arbitrary () diff --git a/tools/stern/src/Stern/API/Routes.hs b/tools/stern/src/Stern/API/Routes.hs index 865e36de266..ba212cc81a9 100644 --- a/tools/stern/src/Stern/API/Routes.hs +++ b/tools/stern/src/Stern/API/Routes.hs @@ -16,6 +16,23 @@ -- with this program. If not, see . {-# OPTIONS_GHC -Wno-deprecations #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Stern.API.Routes ( SternAPI, SternAPIInternal, diff --git a/treefmt.toml b/treefmt.toml index 949295a0b4c..516cbd86fbb 100644 --- a/treefmt.toml +++ b/treefmt.toml @@ -10,6 +10,14 @@ excludes = [ "dist-newstyle/" ] +[formatter.headroom] +command = "hack/bin/headroom-treefmt.sh" +includes = ["*.hs", "*.hsc", "*.rs"] +excludes = [ + "dist-newstyle/", + "services/wire-server-enterprise/*", +] + [formatter.shellcheck] command = "shellcheck" options = ["-x"] From e8988641c782e51f87ba346b09eba35ed4cf565e Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 26 Aug 2026 15:37:11 +0200 Subject: [PATCH 110/113] [WPB-27169] Make haddocks more readable. (#5446) --- .../WPB-27169-make-haddocks-more-readable | 1 + .../src/Wire/ConversationSubsystem/Util.hs | 30 ++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 changelog.d/5-internal/WPB-27169-make-haddocks-more-readable diff --git a/changelog.d/5-internal/WPB-27169-make-haddocks-more-readable b/changelog.d/5-internal/WPB-27169-make-haddocks-more-readable new file mode 100644 index 00000000000..ccada72ee70 --- /dev/null +++ b/changelog.d/5-internal/WPB-27169-make-haddocks-more-readable @@ -0,0 +1 @@ +Make haddocks more readable. diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Util.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Util.hs index b6e3c61c5ed..4e8b7dc27b8 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Util.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Util.hs @@ -146,13 +146,12 @@ ensureConnectedOrSameTeam lusr others = do ensureConnectedToLocalsOrSameTeam lusr locals ensureConnectedToRemotes lusr remotes --- | Check that the given user is part of the same team(s) as the other users --- OR that there is a connection (either direct or implicit via team --- collaborations.) +-- | Check that the given users are connected to `u`, or that `u` is +-- member of the same team, or that there is a collaboration. -- --- Team members are always considered connected, so we only check --- 'ensureConnected' for non-team-members of the _given_ user. Implicit --- connections are created per team, so we count them as team membership here. +-- A collaboration exists between users `u` and `v` if either `u` is +-- collaborating with `v`s team and has "implicit connection" +-- permissions, or that `v` is collaborating in `u`s team. ensureConnectedToLocalsOrSameTeam :: ( Member BrigAPIAccess r, Member (ErrorS 'NotConnected) r, @@ -165,26 +164,29 @@ ensureConnectedToLocalsOrSameTeam :: Sem r () ensureConnectedToLocalsOrSameTeam _ [] = pure () ensureConnectedToLocalsOrSameTeam (tUnqualified -> u) uids = do + -- own team uTeams <- getUserTeams u + -- teams with which `u` collaborates icTeams <- getUserCollaborationTeams + -- users collaborating with `u`s team icUsers <- getTeamCollaborators uTeams - -- We collect all the relevant uids from same teams as the origin user + -- Subset of uids from same team as `u` (the user who wants to connect) sameTeamUids <- forM (uTeams `union` icTeams) $ \team -> fmap (view Mem.userId) <$> TeamSubsystem.internalSelectTeamMembers team uids - -- Do not check connections for users that are on the same team + -- Do not check connections for team members and collaborators ensureConnectedToLocals u ((uids \\ join sameTeamUids) \\ icUsers) where - -- Teams in which the user who wants to reach out is member with - -- `ImplicitConnection` permission. + -- Teams in which `u` (the user who wants to connect) is + -- collaborator with `ImplicitConnection` permission. getUserCollaborationTeams :: (Member TeamCollaboratorsSubsystem r') => Sem r' [TeamId] getUserCollaborationTeams = gTeam <$$> (filter (flip hasPermission CollaboratorPermission.ImplicitConnection) <$> internalGetTeamCollaborations u) - -- We do not check the permissions of team collaborators if a user tries to - -- reach out to them (if they are in the same team.) The reasoning behind - -- this is that team collaborators have implicitly agreed to be - -- collaborated with. + -- We do not check the permissions of team collaborators if a user + -- tries to reach out to them (if they are in the same team.) The + -- reasoning behind this is that team collaborators have + -- implicitly agreed to be collaborated with. getTeamCollaborators :: (Member TeamCollaboratorsSubsystem r') => [TeamId] -> Sem r' [UserId] getTeamCollaborators teams = gUser <$$> internalGetTeamCollaboratorsWithIds (Set.fromList teams) (Set.fromList uids) From e6b5ca3ed378f452b6648d8b9dcd31b5c7842206 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 26 Aug 2026 18:18:12 +0200 Subject: [PATCH 111/113] [WPB-28299] Finalize api version 17 (#5482) --------- Co-authored-by: Gautier DI FOLCO --- .../WPB-28299-finalize-api-version-17 | 1 + integration/test/Test/Version.hs | 6 +- integration/test/Testlib/Env.hs | 2 +- libs/wire-api/src/Wire/API/Routes/Version.hs | 2 +- services/brig/docs/swagger-v17.json | 31088 +++++++++++++++- 5 files changed, 31093 insertions(+), 6 deletions(-) create mode 100644 changelog.d/1-api-changes/WPB-28299-finalize-api-version-17 diff --git a/changelog.d/1-api-changes/WPB-28299-finalize-api-version-17 b/changelog.d/1-api-changes/WPB-28299-finalize-api-version-17 new file mode 100644 index 00000000000..fbdd04615a8 --- /dev/null +++ b/changelog.d/1-api-changes/WPB-28299-finalize-api-version-17 @@ -0,0 +1 @@ +Finalize api version 17. diff --git a/integration/test/Test/Version.hs b/integration/test/Test/Version.hs index c5f2ec922d1..12346b09b34 100644 --- a/integration/test/Test/Version.hs +++ b/integration/test/Test/Version.hs @@ -60,9 +60,9 @@ testVersion (Versioned' v) = withModifiedBackend domain <- resp.json %. "domain" & asString federation <- resp.json %. "federation" & asBool - -- during a version bump, there are two development versions until the - -- older one is released (i.e. moved to supported and frozen) - dev `shouldMatchSet` [17, 18 :: Int] + -- currently there is one development version + -- it is however theoretically possible to have multiple development versions + length dev `shouldMatchInt` 1 domain `shouldMatch` dom federation `shouldMatch` True diff --git a/integration/test/Testlib/Env.hs b/integration/test/Testlib/Env.hs index cdd9f7e9250..dd37e9c8f56 100644 --- a/integration/test/Testlib/Env.hs +++ b/integration/test/Testlib/Env.hs @@ -133,7 +133,7 @@ mkGlobalEnv cfgFile = do gFederationV1Domain = intConfig.federationV1.originDomain, gFederationV2Domain = intConfig.federationV2.originDomain, gDynamicDomains = (.domain) <$> Map.elems intConfig.dynamicBackends, - gDefaultAPIVersion = 17, + gDefaultAPIVersion = 18, gManager = manager, gServicesCwdBase = devEnvProjectRoot <&> ( "services"), gBackendResourcePool = resourcePool, diff --git a/libs/wire-api/src/Wire/API/Routes/Version.hs b/libs/wire-api/src/Wire/API/Routes/Version.hs index df2b9b3aac5..119c62b3dfa 100644 --- a/libs/wire-api/src/Wire/API/Routes/Version.hs +++ b/libs/wire-api/src/Wire/API/Routes/Version.hs @@ -294,7 +294,7 @@ isDevelopmentVersion V13 = False isDevelopmentVersion V14 = False isDevelopmentVersion V15 = False isDevelopmentVersion V16 = False -isDevelopmentVersion V17 = True +isDevelopmentVersion V17 = False isDevelopmentVersion V18 = True developmentVersions :: [Version] diff --git a/services/brig/docs/swagger-v17.json b/services/brig/docs/swagger-v17.json index 84763a895ae..2c63648dc97 100644 --- a/services/brig/docs/swagger-v17.json +++ b/services/brig/docs/swagger-v17.json @@ -1 +1,31087 @@ -{"info":{"description":"## Authentication / Authorization\n\nThe end-points in this API support differing authorization protocols:\nsome are unauthenticated (`/api-version`, `/login`), some require\n[zauth](), and some support both [zauth]() and [oauth]().\n\nThe end-points that require zauth are labelled so in the description\nbelow. The end-points that support oauth as an alternative to zauth\nhave the required oauth scopes listed in the same description.\n\nFuther reading:\n- https://docs.wire.com/developer/reference/oauth.html\n- https://github.com/wireapp/wire-server/blob/develop/libs/wire-api/src/Wire/API/Routes/Public.hs (search for HasSwagger instances)\n- `curl https://staging-nginz-https.zinfra.io/v4/api/swagger.json | jq '.security, .securityDefinitions`\n\n### SSO Endpoints\n\n#### Overview\n\n`/sso/metadata` will be requested by the IdPs to learn how to talk to wire.\n\n`/sso/initiate-login`, `/sso/finalize-login` are for the SAML authentication handshake performed by a user in order to log into wire. They are not exactly standard in their details: they may return HTML or XML; redirect to error URLs instead of throwing errors, etc.\n\n`/identity-providers` end-points are for use in the team settings page when IdPs are registered. They talk json.\n\n\n#### Configuring IdPs\n\nIdPs usually allow you to copy the metadata into your clipboard. That should contain all the details you need to post the idp in your team under `/identity-providers`. (Team id is derived from the authorization credentials of the request.)\n\n##### okta.com\n\nOkta will ask you to provide two URLs when you set it up for talking to wireapp:\n\n1. The `Single sign on URL`. This is the end-point that accepts the user's credentials after successful authentication against the IdP. Choose `/sso/finalize-login` with schema and hostname of the wire server you are configuring.\n\n2. The `Audience URI`. You can find this in the metadata returned by the `/sso/metadata` end-point. It is the contents of the `md:OrganizationURL` element.\n\n##### centrify.com\n\nCentrify allows you to upload the metadata xml document that you get from the `/sso/metadata` end-point. You can also enter the metadata url and have centrify retrieve the xml, but to guarantee integrity of the setup, the metadata should be copied from the team settings page and pasted into the centrify setup page without any URL indirections.\n\n## Federation errors\n\nEndpoints involving federated calls to other domains can return some extra failure responses, common to all endpoints. Instead of listing them as possible responses for each endpoint, we document them here.\n\nFor errors that are more likely to be transient, we suggest clients to retry whatever request resulted in the error. Transient errors are indicated explicitly below.\n\n**Note**: when a failure occurs as a result of making a federated RPC to another backend, the error response contains the following extra fields:\n\n - `type`: \"federation\" (just the literal string in quotes, which can be used as an error type identifier when parsing errors)\n - `domain`: the target backend of the RPC that failed;\n - `path`: the path of the RPC that failed.\n\n### Domain errors\n\nErrors in this category result from trying to communicate with a backend that is considered non-existent or invalid. They can result from invalid user input or client issues, but they can also be a symptom of misconfiguration in one or multiple backends. These errors have a 4xx status code.\n\n - **Remote backend not found** (status: 422, label: `invalid-domain`): This backend attempted to contact a backend which does not exist or is not properly configured. For the most part, clients can consider this error equivalent to a domain not existing, although it should be noted that certain mistakes in the DNS configuration on a remote backend can lead to the backend not being recognized, and hence to this error. It is therefore not advisable to take any destructive action upon encountering this error, such as deleting remote users from conversations.\n - **Federation denied locally** (status: 400, label: `federation-denied`): This backend attempted an RPC to a non-whitelisted backend. Similar considerations as for the previous error apply.\n - **Federation not enabled** (status: 400, label: `federation-not-enabled`): Federation has not been configured for this backend. This will happen if a federation-aware client tries to talk to a backend for which federation is disabled, or if federation was disabled on the backend after reaching a federation-specific state (e.g. conversations with remote users). There is no way to cleanly recover from these errors at this point.\n\n### Local federation errors\n\nAn error in this category likely indicates an issue with the configuration of federation on the local backend. Possibly transient errors are indicated explicitly below. All these errors have a 500 status code.\n\n - **Federation unavailable** (status: 500, label: `federation-not-available`): Federation is configured for this backend, but the local federator cannot be reached. This can be transient, so clients should retry the request.\n - **Federation not implemented** (status: 422, label: `federation-not-implemented`): Federated behaviour for a certain endpoint is not yet implemented.\n - **Federator discovery failed** (status: 400, label: `discovery-failure`): A DNS error occurred during discovery of a remote backend. This can be transient, so clients should retry the request.\n - **Local federation error** (status: 500, label: `federation-local-error`): An error occurred in the communication between this backend and its local federator. These errors are most likely caused by bugs in the backend, and should be reported as such.\n\n### Remote federation errors\n\nErrors in this category are returned in case of communication issues between the local backend and a remote one, or if the remote side encountered an error while processing an RPC. Some errors in this category might be caused by incorrect client behaviour, wrong user input, or incorrect certificate configuration. Possibly transient errors are indicated explicitly. We use non-standard 5xx status codes for these errors.\n\n - **HTTP2 error** (status: 533, label: `federation-http2-error`): The current federator encountered an error when making an HTTP2 request to a remote one. Check the error message for more details.\n - **Connection refused** (status: 521, label: `federation-connection-refused`): The local federator could not connect to a remote one. This could be transient, so clients should retry the request.\n - **TLS failure**: (status: 525, label: `federation-tls-error`): An error occurred during the TLS handshake between the local federator and a remote one. This is most likely due to an issue with the certificate on the remote end.\n - **Remote federation error** (status: 533, label: `federation-remote-error`): The remote backend could not process a request coming from this backend. Check the error message for more details.\n - **Version negotiation error** (status: 533, label: `federation-version-error`): The remote backend returned invalid version information.\n\n### Backend compatibility errors\n\nAn error in this category will be returned when this backend makes an invalid or unsupported RPC to another backend. This can indicate some incompatibility between backends or a backend bug. These errors are unlikely to be transient, so retrying requests is *not* advised.\n\n - **Version mismatch** (status: 531, label: `federation-version-mismatch`): A remote backend is running an unsupported version of the federator.\n - **Invalid content type** (status: 533, label: `federation-invalid-content-type`): An RPC to another backend returned with an invalid content type.\n - **Unsupported content type** (status: 533, label: `federation-unsupported-content-type`): An RPC to another backend returned with an unsupported content type.\n","title":"Wire-Server API","version":""},"servers":[{"url":"/v17"}],"paths":{"/api-version":{"get":{"description":" [internal route ID: \"get-version\"]\n\n","operationId":"get-version","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/VersionInfo_NTEzMTgzNDQ0"}}},"description":""}}}},"/users/{uid_domain}/{uid}":{"get":{"summary":"Get a user by Domain and UserId","description":" [internal route ID: \"get-user-qualified\"]\n\n","operationId":"get-user-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}}},"description":"User found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`uid_domain` or `uid` or User not found (label: `not-found`)"}}}},"/users/{uid}/email":{"put":{"summary":"Resend email address validation email.","description":" [internal route ID: \"update-user-email\"]\n\nIf the user has a pending email validation, the validation email will be resent.","operationId":"update-user-email","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/EmailUpdate_NjQ5MDg1OTY0"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""}}}},"/list-users":{"post":{"summary":"List users","description":" [internal route ID: \"list-users-by-ids-or-handles\"]\n\nThe 'qualified_ids' and 'qualified_handles' parameters are mutually exclusive.","operationId":"list-users-by-ids-or-handles","parameters":[{"description":"Include whether each local user can currently be contacted","in":"query","name":"include-contact-status","required":false,"schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ListUsersQuery"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ListUsersById_LTQ5MTE3NDc0"}}},"description":""}}}},"/verification-code/send":{"post":{"summary":"Send a verification code to a given email address.","description":" [internal route ID: \"send-verification-code\"]\n\n","operationId":"send-verification-code","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SendVerificationCode_MjgxNDgxODE2"}}},"required":true},"responses":{"200":{"description":"Verification code sent."}}}},"/users/{uid}/rich-info":{"get":{"summary":"Get a user's rich info","description":" [internal route ID: \"get-rich-info\"]\n\n","operationId":"get-rich-info","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RichInfoAssocList"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RichInfoAssocList"}}},"description":"Rich info about the user"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}}}},"/users/{uid_domain}/{uid}/supported-protocols":{"get":{"summary":"Get a user's supported protocols","description":" [internal route ID: \"get-supported-protocols\"]\n\n","operationId":"get-supported-protocols","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array","uniqueItems":true}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array","uniqueItems":true}}},"description":"Protocols supported by the user"}}}},"/users/{uid}/searchable":{"post":{"summary":"Set user's visibility in search","description":" [internal route ID: \"set-user-searchable\"]\n\n","operationId":"set-user-searchable","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SetSearchable_NDAxODAxODI5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""}}}},"/self":{"get":{"summary":"Get your own profile","description":" [internal route ID: \"get-self\"]\n\n\nOAuth scope: `read:self`","operationId":"get-self","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/User_NjA4OTQwMTQ4"}}},"description":""}}},"put":{"summary":"Update your profile.","description":" [internal route ID: \"put-self\"]\n\n","operationId":"put-self","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserUpdate_MjQ4NTEwOTQz"}}},"required":true},"responses":{"200":{"description":"User updated"}}},"delete":{"summary":"Initiate account deletion.","description":" [internal route ID: \"delete-self\"]\n\nif the account has a verified identity, a verification code is sent and needs to be confirmed to authorise the deletion. if the account has no verified identity but a password, it must be provided. if password is correct, or if neither a verified identity nor a password exists, account deletion is scheduled immediately.","operationId":"delete-self","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteUser_NjE0MjE2Mjkz"}}},"required":true},"responses":{"200":{"description":"Deletion is initiated."},"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionCodeTimeout_LTU1MTk0NDI3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeletionCodeTimeout_LTU1MTk0NDI3"}}},"description":"Deletion is pending verification with a code."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-user","message":"Invalid user"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-user"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid user (label: `invalid-user`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-self-delete-for-team-owner","message":"Team owners are not allowed to delete themselves; ask a fellow owner"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-self-delete-for-team-owner","pending-delete","missing-auth","invalid-credentials","invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team owners are not allowed to delete themselves; ask a fellow owner (label: `no-self-delete-for-team-owner`)\n\nA verification code for account deletion is still pending (label: `pending-delete`)\n\nRe-authentication via password required (label: `missing-auth`)\n\nAuthentication failed (label: `invalid-credentials`)\n\nInvalid verification code (label: `invalid-code`)"}}}},"/self/email":{"delete":{"summary":"Remove your email address.","description":" [internal route ID: \"remove-email\"]\n\nYour email address can only be removed if you also have a phone number.","operationId":"remove-email","responses":{"200":{"description":"Identity Removed"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"last-identity","message":"The last user identity cannot be removed."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["last-identity","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"last-identity","message":"The last user identity cannot be removed."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["last-identity","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The last user identity cannot be removed. (label: `last-identity`)\n\nThe user has no verified email (label: `no-identity`)"}}}},"/self/password":{"put":{"summary":"Change your password.","description":" [internal route ID: \"change-password\"]\n\n","operationId":"change-password","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordChange_MTgzMDM2NTY2"}}},"required":true},"responses":{"200":{"description":"Password Changed"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","no-identity"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nThe user has no verified email (label: `no-identity`)"},"409":{"content":{"application/json":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password change, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password change, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password change, new and old password must be different. (label: `password-must-differ`)"}}},"head":{"summary":"Check that your password is set.","description":" [internal route ID: \"check-password-exists\"]\n\n","operationId":"check-password-exists","responses":{"200":{"description":"Password is set"},"404":{"description":"Password is not set"}}}},"/self/locale":{"put":{"summary":"Change your locale.","description":" [internal route ID: \"change-locale\"]\n\n","operationId":"change-locale","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LocaleUpdate_LTgzNjgyOTEw"}}},"required":true},"responses":{"200":{"description":"Local Changed"}}}},"/self/handle":{"put":{"summary":"Change your handle.","description":" [internal route ID: \"change-handle\"]\n\n","operationId":"change-handle","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/HandleUpdate_NTI4NDk1OTAx"}}},"required":true},"responses":{"200":{"description":"Handle Changed"}}}},"/self/supported-protocols":{"put":{"summary":"Change your supported protocols","description":" [internal route ID: \"change-supported-protocols\"]\n\n","operationId":"change-supported-protocols","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SupportedProtocolUpdate_LTE3Njk3MDM4"}}},"required":true},"responses":{"200":{"description":"Supported protocols changed"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-protocol-error","message":"MLS protocol cannot be removed"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS protocol cannot be removed (label: `mls-protocol-error`)"}}}},"/upgrade-personal-to-team":{"post":{"summary":"Upgrade personal user to team owner","description":" [internal route ID: \"upgrade-personal-to-team\"]\n\n","operationId":"upgrade-personal-to-team","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/BindingNewTeamUser_LTY0MDQxMDEw"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserTeam_MzI4NDQ1Mzkw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateUserTeam_MzI4NDQ1Mzkw"}}},"description":"Team created"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"user-already-in-a-team","message":"Switching teams is not allowed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-already-in-a-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-already-in-a-team","message":"Switching teams is not allowed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-already-in-a-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Switching teams is not allowed (label: `user-already-in-a-team`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"User not found (label: `not-found`)"}}}},"/register":{"post":{"summary":"Register a new user.","description":" [internal route ID: \"register\"]\n\nIf the environment where the registration takes place is private and a registered email address is not whitelisted, a 403 error is returned.","operationId":"register","parameters":[{"in":"header","name":"X-Forwarded-For","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewUser_PlainTextPassword_8_LTI4MzI5NzQx"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User_NjA4OTQwMTQ4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/User_NjA4OTQwMTQ4"}}},"description":"User created and pending activation","headers":{"Location":{"description":"UserId","schema":{"format":"uuid","type":"string"}},"Set-Cookie":{"description":"Cookie","schema":{"type":"string"}}}},"400":{"content":{"application/json":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code","invalid-email","invalid-phone"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code","invalid-email","invalid-phone"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid invitation code. (label: `invalid-invitation-code`)\n\nInvalid e-mail address. (label: `invalid-email`)\n\nInvalid mobile phone number (label: `invalid-phone`) or `body` or `X-Forwarded-For`"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unauthorized","message":"Unauthorized e-mail address"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorized","missing-identity","blacklisted-email","too-many-team-members","user-creation-restricted","ephemeral-user-creation-disabled","managed-by-scim"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorized","message":"Unauthorized e-mail address"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorized","missing-identity","blacklisted-email","too-many-team-members","user-creation-restricted","ephemeral-user-creation-disabled","managed-by-scim"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorized e-mail address (label: `unauthorized`)\n\nUsing an invitation code requires registering the given email. (label: `missing-identity`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nToo many members in this team. (label: `too-many-team-members`)\n\nThis instance does not allow creation of personal users or teams. (label: `user-creation-restricted`)\n\nEphemeral user creation is disabled on this instance. (label: `ephemeral-user-creation-disabled`)\n\nUpdating name is not allowed, because it is managed by SCIM, or E2EId is enabled (label: `managed-by-scim`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"invalid-code","message":"User does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"User does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"User does not exist (label: `invalid-code`)\n\nInvalid activation code (label: `invalid-code`)"},"409":{"content":{"application/json":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}}}},"/delete":{"post":{"summary":"Verify account deletion with a code.","description":" [internal route ID: \"verify-delete\"]\n\n","operationId":"verify-delete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/VerifyDeleteUser_Njc1NDQ1MDIy"}}},"required":true},"responses":{"200":{"description":"Deletion is initiated."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-code","message":"Invalid verification code"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid verification code (label: `invalid-code`)"}}}},"/activate":{"get":{"summary":"Activate (i.e. confirm) an email address.","description":" [internal route ID: \"get-activate\"]\n\nSee also 'POST /activate' which has a larger feature set.","operationId":"get-activate","parameters":[{"description":"Activation key","in":"query","name":"key","required":true,"schema":{"type":"string"}},{"description":"Activation code","in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}}},"description":"Activation successful.\n\nActivation successful. (Dry run)\n\nActivation successful."},"204":{"description":"A recent activation was already successful."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-phone","message":"Invalid mobile phone number"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-phone","invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `code` or `key`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"Invalid activation code"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}}},"post":{"summary":"Activate (i.e. confirm) an email address.","description":" [internal route ID: \"post-activate\"]\n\nActivation only succeeds once and the number of failed attempts for a valid key is limited.","operationId":"post-activate","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Activate_MzUzNzIxODUw"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ActivationResponse_LTIyOTY5NDE3"}}},"description":"Activation successful.\n\nActivation successful. (Dry run)\n\nActivation successful."},"204":{"description":"A recent activation was already successful."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-phone","message":"Invalid mobile phone number"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-phone","invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"Invalid activation code"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}}}},"/activate/send":{"post":{"summary":"Send (or resend) an email activation code.","description":" [internal route ID: \"post-activate-send\"]\n\n","operationId":"post-activate-send","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SendActivationCode_LTgyNDAxNzEy"}}},"required":true},"responses":{"200":{"description":"Activation code sent."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"blacklisted-email","message":"The given e-mail address has been blacklisted due to a permanent bounce or a complaint."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["blacklisted-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"},"451":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":451,"label":"domain-blocked-for-registration","message":"[Customer extension] The email domain has been blocked for Wire users. Please contact your IT department."},"properties":{"code":{"enum":[451],"type":"integer"},"label":{"enum":["domain-blocked-for-registration"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"[Customer extension] The email domain has been blocked for Wire users. Please contact your IT department. (label: `domain-blocked-for-registration`)"}}}},"/password-reset":{"post":{"summary":"Initiate a password reset.","description":" [internal route ID: \"post-password-reset\"]\n\n","operationId":"post-password-reset","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewPasswordReset_LTEyNzAxMTcy"}}},"required":true},"responses":{"201":{"description":"Password reset code created and sent by email."}}}},"/password-reset/complete":{"post":{"summary":"Complete a password reset.","description":" [internal route ID: \"post-password-reset-complete\"]\n\n","operationId":"post-password-reset-complete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CompletePasswordReset_NDcyMjY5OTc4"}}},"required":true},"responses":{"200":{"description":"Password reset successful."},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-code","message":"Invalid password reset code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)"}}}},"/users/{uid_domain}/{uid}/clients/{client}":{"get":{"summary":"Get a specific client of a user","description":" [internal route ID: \"get-user-client-qualified\"]\n\nHint: to list all clients of one or more users, use POST /users/list-clients.","operationId":"get-user-client-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PubClient"}}},"description":""}}}},"/users/list-clients":{"post":{"summary":"List all clients for a set of user ids","description":" [internal route ID: \"list-clients-bulk@v2\"]\n\nIf a backend is unreachable, the clients from that backend will be omitted from the response","operationId":"list-clients-bulk@v2","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LimitedQualifiedUserIdList_500"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"qualified_user_map":{"$ref":"#/components/schemas/QualifiedUserMap_Set_PubClient"}},"type":"object"}}},"description":""}}}},"/users/{uid_domain}/{uid}/prekeys/{client}":{"get":{"summary":"Get a prekey for a specific client of a user.","description":" [internal route ID: \"get-users-prekeys-client-qualified\"]\n\n","operationId":"get-users-prekeys-client-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientPrekey_LTcyODUzMTcw"}}},"description":""}}}},"/users/{uid_domain}/{uid}/prekeys":{"get":{"summary":"Get a prekey for each client of a user.","description":" [internal route ID: \"get-users-prekey-bundle-qualified\"]\n\n","operationId":"get-users-prekey-bundle-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PrekeyBundle_MzgzOTk4MjYz"}}},"description":""}}}},"/users/list-prekeys":{"post":{"summary":"(deprecated) Given a map of user IDs to client IDs return a prekey for each one.","description":" [internal route ID: \"get-multi-user-prekey-bundle-qualified\"]\n\nYou can't request information for more users than maximum conversation size.","operationId":"get-multi-user-prekey-bundle-qualified","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QualifiedUserClients"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QualifiedUserClientPrekeyMapV4_MzcxOTAxMTYy"}}},"description":""}}}},"/clients":{"get":{"summary":"List the registered clients","description":" [internal route ID: \"list-clients\"]\n\n","operationId":"list-clients","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"},"type":"array"}}},"description":"List of clients"}}},"post":{"summary":"Register a new client","description":" [internal route ID: \"add-client\"]\n\n","operationId":"add-client","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewClient_ODg1NjY4Njgy"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}}},"description":"Client registered","headers":{"Location":{"description":"Client ID","schema":{"type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"bad-request","message":"Malformed prekeys uploaded"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["bad-request"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMalformed prekeys uploaded (label: `bad-request`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed","missing-auth","too-many-clients"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nRe-authentication via password required (label: `missing-auth`)\n\nToo many clients (label: `too-many-clients`)"}}}},"/clients/{client}":{"get":{"summary":"Get a registered client by ID","description":" [internal route ID: \"get-client\"]\n\n","operationId":"get-client","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}}},"description":"Client found"},"404":{"description":"`client` or Client not found(**Note**: This error has an empty body for legacy reasons)"}}},"put":{"summary":"Update a registered client","description":" [internal route ID: \"update-client\"]\n\n","operationId":"update-client","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateClient_NzU5MjA4MzI1"}}},"required":true},"responses":{"200":{"description":"Client updated"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-duplicate-public-key","message":"MLS public key for the given signature scheme already exists"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-duplicate-public-key","bad-request"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMLS public key for the given signature scheme already exists (label: `mls-duplicate-public-key`)\n\nMalformed prekeys uploaded (label: `bad-request`)"}}},"delete":{"summary":"Delete an existing client","description":" [internal route ID: \"delete-client\"]\n\n","operationId":"delete-client","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RmClient_MTQ5OTI2MDY3"}}},"required":true},"responses":{"200":{"description":"Client deleted"}}}},"/clients/{client}/capabilities":{"get":{"summary":"Read back what the client has been posting about itself","description":" [internal route ID: \"get-client-capabilities\"]\n\n","operationId":"get-client-capabilities","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientCapabilityList"}}},"description":""}}}},"/clients/{client}/prekeys":{"get":{"summary":"List the remaining prekey IDs of a client","description":" [internal route ID: \"get-client-prekeys\"]\n\n","operationId":"get-client-prekeys","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"maximum":65535,"minimum":0,"type":"integer"},"type":"array"}}},"description":""}}}},"/clients/{client}/nonce":{"get":{"summary":"Get a new nonce for a client CSR","description":" [internal route ID: \"get-nonce\"]\n\nGet a new nonce for a client CSR, specified in the response header `Replay-Nonce` as a uuidv4 in base64url encoding.","operationId":"get-nonce","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content","headers":{"Cache-Control":{"schema":{"type":"string"}},"Replay-Nonce":{"schema":{"type":"string"}}}}}},"head":{"summary":"Get a new nonce for a client CSR","description":" [internal route ID: \"head-nonce\"]\n\nGet a new nonce for a client CSR, specified in the response header `Replay-Nonce` as a uuidv4 in base64url encoding.","operationId":"head-nonce","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"No Content","headers":{"Cache-Control":{"schema":{"type":"string"}},"Replay-Nonce":{"schema":{"type":"string"}}}}}}},"/clients/{cid}/access-token":{"post":{"summary":"Create a JWT DPoP access token","description":" [internal route ID: \"create-access-token\"]\n\nCreate an JWT DPoP access token for the client CSR, given a JWT DPoP proof, specified in the `DPoP` header. The access token will be returned in the JSON response body as a JWT DPoP token.","operationId":"create-access-token","parameters":[{"description":"ClientId","in":"path","name":"cid","required":true,"schema":{"type":"string"}},{"in":"header","name":"DPoP","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DPoPAccessTokenResponse_LTgyODU5MDE3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DPoPAccessTokenResponse_LTgyODU5MDE3"}}},"description":"Access token created","headers":{"Cache-Control":{"schema":{"type":"string"}}}}}}},"/connections/{uid_domain}/{uid}":{"get":{"summary":"Get an existing connection to another user (local or remote)","description":" [internal route ID: \"get-connection\"]\n\n","operationId":"get-connection","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection found"},"404":{"description":"`uid_domain` or `uid` or Connection not found(**Note**: This error has an empty body for legacy reasons)"}}},"put":{"summary":"Update a connection to another user","description":" [internal route ID: \"update-connection\"]\n\n","operationId":"update-connection","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConnectionUpdate_LTU3MTA1OTA5"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection updated"},"204":{"description":"Connection unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-user","message":"Invalid user"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-user"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid user (label: `invalid-user`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-identity","message":"The user has no verified email"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-identity","bad-conn-update","not-connected","connection-limit","missing-legalhold-consent","missing-legalhold-consent-old-clients"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The user has no verified email (label: `no-identity`)\n\nInvalid status transition (label: `bad-conn-update`)\n\nUsers are not connected (label: `not-connected`)\n\nToo many sent/accepted connections (label: `connection-limit`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)"}}},"post":{"summary":"Create a connection to another user","description":" [internal route ID: \"create-connection\"]\n\nYou can have no more than 1000 connections in accepted or sent state","operationId":"create-connection","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection existed"},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"}}},"description":"Connection was created"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-user","message":"Invalid user"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-user"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid user (label: `invalid-user`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-identity","message":"The user has no verified email"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-identity","connection-limit","missing-legalhold-consent","missing-legalhold-consent-old-clients"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The user has no verified email (label: `no-identity`)\n\nToo many sent/accepted connections (label: `connection-limit`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)"}}}},"/list-connections":{"post":{"summary":"List the connections to other users, including remote users","description":" [internal route ID: \"list-connections\"]\n\nThe IDs returned by this endpoint are paginated. To get the first page, make a call with the `paging_state` field set to `null` (or omitted). Whenever the `has_more` field of the response is set to `true`, more results are available, and they can be obtained by calling the endpoint again, but this time passing the value of `paging_state` returned by the previous call. One can continue in this fashion until all results are returned, which is indicated by `has_more` being `false`. Note that `paging_state` should be considered an opaque token. It should not be inspected, or stored, or reused across multiple unrelated invocations of the endpoint.","operationId":"list-connections","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetMuliTabPgRqs_ConcLOrm501_LTIwMjA4NzYw"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MultiTabePg_ConcsLOrRmU_MTgwMzU4OTU5"}}},"description":""}}}},"/search/contacts":{"get":{"summary":"Search for users","description":" [internal route ID: \"search-contacts\"]\n\n","operationId":"search-contacts","parameters":[{"description":"Search query

The search query is normalized: lower-cased and diacritics are removed ('Björn' becomes 'bjorn'). The normalized search query matches accounts that, in this order of priority:

  • are equal to the normalized full handle;
  • are equal to the normalized full user display name;
  • prefix-match the normalized handle;
  • prefix-match the normalized user display name.

NB: '@' Does NOT do anything special, ignoring user display names.

See also: [authoritative ElasticSearch query](https://github.com/wireapp/wire-server/blob/83c25cca6a5e9d2205c102410b452eb78fc50a00/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs#L251-L288)

","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Searched domain. Note: This is optional only for backwards compatibility, future versions will mandate this.","in":"query","name":"domain","required":false,"schema":{"type":"string"}},{"description":"Number of results to return (min: 1, max: 500, default 15)","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":500,"minimum":1,"type":"integer"}},{"description":"Only user types. Omitted or empty (type=) means no filtering.","in":"query","name":"type","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SearchResult_Contact_OTExNzg4MTE0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `insufficient-permissions`)"}}}},"/properties/{key}":{"get":{"summary":"Get a property value","description":" [internal route ID: \"get-property\"]\n\n","operationId":"get-property","parameters":[{"in":"path","name":"key","required":true,"schema":{"format":"printable","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PropertyValue"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PropertyValue"}}},"description":"The property value"},"404":{"description":"`key` or Property not found(**Note**: This error has an empty body for legacy reasons)"}}},"put":{"summary":"Set a user property","description":" [internal route ID: \"set-property\"]\n\n","operationId":"set-property","parameters":[{"in":"path","name":"key","required":true,"schema":{"format":"printable","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PropertyValue"}}},"required":true},"responses":{"200":{"description":"Property set"}}},"delete":{"summary":"Delete a property","description":" [internal route ID: \"delete-property\"]\n\n","operationId":"delete-property","parameters":[{"in":"path","name":"key","required":true,"schema":{"format":"printable","type":"string"}}],"responses":{"200":{"description":"Property deleted"}}}},"/properties":{"get":{"summary":"List all property keys","description":" [internal route ID: \"list-property-keys\"]\n\n","operationId":"list-property-keys","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/ASCII"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/ASCII"},"type":"array"}}},"description":"List of property keys"}}},"delete":{"summary":"Clear all properties","description":" [internal route ID: \"clear-properties\"]\n\n","operationId":"clear-properties","responses":{"200":{"description":"Properties cleared"}}}},"/properties-values":{"get":{"summary":"List all properties with key and value","description":" [internal route ID: \"list-properties\"]\n\n","operationId":"list-properties","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PropertyKeysAndValues"}}},"description":""}}}},"/mls/key-packages/self/{client}":{"put":{"summary":"Upload a fresh batch of key packages and replace the old ones","description":" [internal route ID: \"mls-key-packages-replace\"]\n\nThe request body should be a json object containing a list of base64-encoded key packages. Use this sparingly.","operationId":"mls-key-packages-replace","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Comma-separated list of ciphersuites in hex format (e.g. 0x0002)","in":"query","name":"ciphersuites","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageUpload_NTQ2Mjk2NzEx"}}},"required":true},"responses":{"201":{"description":"Key packages replaced"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-protocol-error","message":"MLS protocol error"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `ciphersuites`\n\nMLS protocol error (label: `mls-protocol-error`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-identity-mismatch","message":"Key package credential does not match qualified client ID"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-identity-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Key package credential does not match qualified client ID (label: `mls-identity-mismatch`)"}}},"post":{"summary":"Upload a fresh batch of key packages","description":" [internal route ID: \"mls-key-packages-upload\"]\n\nThe request body should be a json object containing a list of base64-encoded key packages.","operationId":"mls-key-packages-upload","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageUpload_NTQ2Mjk2NzEx"}}},"required":true},"responses":{"201":{"description":"Key packages uploaded"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-protocol-error","message":"MLS protocol error"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMLS protocol error (label: `mls-protocol-error`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-identity-mismatch","message":"Key package credential does not match qualified client ID"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-identity-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Key package credential does not match qualified client ID (label: `mls-identity-mismatch`)"}}},"delete":{"summary":"Delete all key packages for a given ciphersuite and client","description":" [internal route ID: \"mls-key-packages-delete\"]\n\n","operationId":"mls-key-packages-delete","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Ciphersuite in hex format (e.g. 0x0002)","in":"query","name":"ciphersuite","required":true,"schema":{"type":"number"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteKeyPackages_LTQxNTcxNjY3"}}},"required":true},"responses":{"201":{"description":"OK"}}}},"/mls/key-packages/claim/{user_domain}/{user}":{"post":{"summary":"Claim one key package for each client of the given user","description":" [internal route ID: \"mls-key-packages-claim\"]\n\nOnly key packages for the specified ciphersuite are claimed.","operationId":"mls-key-packages-claim","parameters":[{"in":"path","name":"user_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"user","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Ciphersuite in hex format (e.g. 0x0002)","in":"query","name":"ciphersuite","required":true,"schema":{"type":"number"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyPackageBundle_MjU2MjY0MDU2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageBundle_MjU2MjY0MDU2"}}},"description":"Claimed key packages"}}}},"/mls/key-packages/self/{client}/count":{"get":{"summary":"Return the number of unclaimed key packages for a given ciphersuite and client","description":" [internal route ID: \"mls-key-packages-count\"]\n\n","operationId":"mls-key-packages-count","parameters":[{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Ciphersuite in hex format (e.g. 0x0002)","in":"query","name":"ciphersuite","required":true,"schema":{"type":"number"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyPackageCount_LTYwNDg5MDcz"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/KeyPackageCount_LTYwNDg5MDcz"}}},"description":"Number of key packages"}}}},"/handles":{"post":{"summary":"Check availability of user handles","description":" [internal route ID: \"check-user-handles\"]\n\n","operationId":"check-user-handles","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CheckHandles_LTc0OTkxMzAx"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Handle"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Handle"},"type":"array"}}},"description":"List of free handles"}}}},"/handles/{handle}":{"head":{"summary":"Check whether a user handle can be taken","description":" [internal route ID: \"check-user-handle\"]\n\n","operationId":"check-user-handle","parameters":[{"in":"path","name":"handle","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"Handle is taken"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-handle","message":"The given handle is invalid (less than 2 or more than 256 characters; chars not in \"a-z0-9_.-\"; or on the blocklist)"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-handle"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given handle is invalid (less than 2 or more than 256 characters; chars not in \"a-z0-9_.-\"; or on the blocklist) (label: `invalid-handle`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Handle not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`handle` not found\n\nHandle not found (label: `not-found`)"}}}},"/teams/{tid}/search":{"get":{"summary":"Browse team for members (requires add-user permission)","description":" [internal route ID: \"browse-team\"]\n\n","operationId":"browse-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Search expression","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"description":"Role filter, eg. `member,partner`. Empty list means do not filter.","in":"query","name":"frole","required":false,"schema":{"items":{"enum":["owner","admin","member","partner"],"type":"string"},"type":"array"}},{"description":"Can be one of name, handle, email, saml_idp, managed_by, role, created_at.","in":"query","name":"sortby","required":false,"schema":{"enum":["name","handle","email","saml_idp","managed_by","role","created_at"],"type":"string"}},{"description":"Can be one of asc, desc.","in":"query","name":"sortorder","required":false,"schema":{"enum":["asc","desc"],"type":"string"}},{"description":"Number of results to return (min: 1, max: 500, default: 15)","in":"query","name":"size","required":false,"schema":{"maximum":500,"minimum":1,"type":"integer"}},{"description":"Optional, when not specified, the first page will be returned. Every returned page contains a `paging_state`, this should be supplied to retrieve the next page.","in":"query","name":"pagingState","required":false,"schema":{"type":"string"}},{"description":"Filter for (un-)verified email","in":"query","name":"email","required":false,"schema":{"enum":["unverified","verified"],"type":"string"}},{"description":"Optional, return only non-searchable members when false.","in":"query","name":"searchable","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResult_TeamContact_LTE0NjQ0NzMw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SearchResult_TeamContact_LTE0NjQ0NzMw"}}},"description":"Search results"}}}},"/access":{"post":{"summary":"Obtain an access tokens for a cookie","description":" [internal route ID: \"access\"]\n\nYou can provide only a cookie or a cookie and token. Every other combination is invalid. Access tokens can be given as query parameter or authorisation header, with the latter being preferred.","operationId":"access","parameters":[{"in":"query","name":"client_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)"}}}},"/login":{"post":{"summary":"Authenticate a user to obtain a cookie and first access token","description":" [internal route ID: \"login\"]\n\nLogins are throttled at the server's discretion","operationId":"login","parameters":[{"description":"Request a persistent cookie instead of a session cookie","in":"query","name":"persist","required":false,"schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Login_LTgyNTIzMTM1"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AccessToken_ODIyMTczMjMw"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed","pending-activation","suspended","invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nAccount pending activation (label: `pending-activation`)\n\nAccount suspended (label: `suspended`)\n\nAuthentication failed (label: `invalid-credentials`)"}}}},"/access/logout":{"post":{"summary":"Log out in order to remove a cookie from the server","description":" [internal route ID: \"logout\"]\n\nCalling this endpoint will effectively revoke the given cookie and subsequent calls to /access with the same cookie will result in a 403.","operationId":"logout","responses":{"200":{"description":"Logout"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)"}}}},"/access/self/email":{"put":{"summary":"Change your email address","description":" [internal route ID: \"change-self-email\"]\n\n","operationId":"change-self-email","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/EmailUpdate_NjQ5MDg1OTY0"}}},"required":true},"responses":{"202":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"Update accepted and pending activation of the new email"},"204":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"No update, current and new email address are the same\n\nEmail address activated"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid e-mail address. (label: `invalid-email`) or `body`"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","blacklisted-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"key-exists","message":"The given e-mail address is in use."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["key-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The given e-mail address is in use. (label: `key-exists`)"}}}},"/cookies":{"get":{"summary":"Retrieve the list of cookies currently stored for the user","description":" [internal route ID: \"list-cookies\"]\n\n","operationId":"list-cookies","parameters":[{"description":"Filter by label (comma-separated list)","in":"query","name":"labels","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CookieList_LTM4MzYwNzAz"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CookieList_LTM4MzYwNzAz"}}},"description":"List of cookies"}}}},"/cookies/remove":{"post":{"summary":"Revoke stored cookies","description":" [internal route ID: \"remove-cookies\"]\n\n","operationId":"remove-cookies","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RemoveCookies_OTYwMTI0NDMy"}}},"required":true},"responses":{"200":{"description":"Cookies revoked"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)"}}}},"/calls/config/v2":{"get":{"summary":"Retrieve all TURN server addresses and credentials. Clients are expected to do a DNS lookup to resolve the IP addresses of the given hostnames ","description":" [internal route ID: \"get-calls-config-v2\"]\n\n","operationId":"get-calls-config-v2","parameters":[{"description":"Limit resulting list. Allowed values [1..10]","in":"query","name":"limit","required":false,"schema":{"maximum":10,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RTCConfiguration_LTIwOTc4OTk0"}}},"description":""}}}},"/teams/{tid}/invitations":{"get":{"summary":"List the sent team invitations","description":" [internal route ID: \"get-team-invitations\"]\n\n","operationId":"get-team-invitations","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Invitation id to start from (ascending).","in":"query","name":"start","required":false,"schema":{"format":"uuid","type":"string"}},{"description":"Number of results to return (default 100, max 500).","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":500,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitationList_ODk4NTQxODc3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InvitationList_ODk4NTQxODc3"}}},"description":"List of sent invitations"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}}},"post":{"summary":"Create and send a new team invitation.","description":" [internal route ID: \"send-team-invitation\"]\n\nInvitations are sent by email. The maximum allowed number of pending team invitations is equal to the team size.","operationId":"send-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InvitationRequest_LTcyMDIzNDc0"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}}},"description":"Invitation was created and sent.","headers":{"Location":{"schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code","invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid invitation code. (label: `invalid-invitation-code`)\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions","too-many-team-invitations","blacklisted-email","no-identity","no-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)\n\nToo many team invitations for this team (label: `too-many-team-invitations`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nThe user has no verified email (label: `no-identity`)\n\nThis operation requires the user to have a verified email address. (label: `no-email`)"}}}},"/teams/{tid}/invitations/{iid}":{"get":{"summary":"Get a pending team invitation by ID.","description":" [internal route ID: \"get-team-invitation\"]\n\n","operationId":"get-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"iid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}}},"description":"Invitation"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Notification not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Notification not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `iid` or Notification not found. (label: `not-found`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"duplicate-entry","message":"Entry already exists"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["duplicate-entry"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Entry already exists (label: `duplicate-entry`)"}}},"delete":{"summary":"Delete a pending team invitation by ID.","description":" [internal route ID: \"delete-team-invitation\"]\n\n","operationId":"delete-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"iid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"Invitation deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}}}},"/teams/invitations/info":{"get":{"summary":"Get invitation info given a code.","description":" [internal route ID: \"get-team-invitation-info\"]\n\n","operationId":"get-team-invitation-info","parameters":[{"description":"Invitation code","in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitationUserView_LTUyMTE3Nzkz"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InvitationUserView_LTUyMTE3Nzkz"}}},"description":"Invitation info"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `code`\n\nInvalid invitation code. (label: `invalid-invitation-code`)"}}}},"/teams/invitations/by-email":{"head":{"summary":"Check if there is an invitation pending given an email address.","description":" [internal route ID: \"head-team-invitations\"]\n\n","operationId":"head-team-invitations","parameters":[{"description":"Email address","in":"query","name":"email","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Pending invitation exists."},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"No pending invitations exists."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"No pending invitations exists."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"No pending invitations exists. (label: `not-found`)"},"409":{"content":{"application/json":{"schema":{"example":{"code":409,"label":"conflicting-invitations","message":"Multiple conflicting invitations to different teams exists."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["conflicting-invitations"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"conflicting-invitations","message":"Multiple conflicting invitations to different teams exists."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["conflicting-invitations"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Multiple conflicting invitations to different teams exists. (label: `conflicting-invitations`)"}}}},"/teams/{tid}/size":{"get":{"summary":"Get the number of team members as an integer","description":" [internal route ID: \"get-team-size\"]\n\nCan be out of sync by roughly the `refresh_interval` of the ES index.","operationId":"get-team-size","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamSize_LTMzMzk2MTk1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSize_LTMzMzk2MTk1"}}},"description":"Number of team members"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid invitation code. (label: `invalid-invitation-code`)"}}}},"/teams/invitations/accept":{"post":{"summary":"Accept a team invitation, changing a personal account into a team member account.","description":" [internal route ID: \"accept-team-invitation\"]\n\n","operationId":"accept-team-invitation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AcceptTeamInvitation_Nzg5NzI3MjA2"}}},"required":true},"responses":{"200":{"description":"Team invitation accepted."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-auth","message":"Re-authentication via password required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-auth","invalid-credentials","missing-identity","too-many-team-members"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Re-authentication via password required (label: `missing-auth`)\n\nAuthentication failed (label: `invalid-credentials`)\n\nUsing an invitation code requires registering the given email. (label: `missing-identity`)\n\nToo many members in this team. (label: `too-many-team-members`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"invalid-code","message":"Invalid activation code"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["invalid-code","not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)\n\nNo pending invitations exists. (label: `not-found`)"}}}},"/system/settings/unauthorized":{"get":{"summary":"Returns a curated set of system configuration settings.","description":" [internal route ID: \"get-system-settings-unauthorized\"]\n\n","operationId":"get-system-settings-unauthorized","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SystemSettingsPublic_LTgwNTMxNjU2"}}},"description":""}}}},"/system/settings":{"get":{"summary":"Returns a curated set of system configuration settings for authorized users.","description":" [internal route ID: \"get-system-settings\"]\n\n","operationId":"get-system-settings","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SystemSettings_ODU3MDk5MTA3"}}},"description":""}}}},"/oauth/clients/{OAuthClientId}":{"get":{"summary":"Get OAuth client information","description":" [internal route ID: \"get-oauth-client\"]\n\n","operationId":"get-oauth-client","parameters":[{"description":"The ID of the OAuth client","in":"path","name":"OAuthClientId","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthClient_NzExMTI5NTIy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OAuthClient_NzExMTI5NTIy"}}},"description":"OAuth client found"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"forbidden","message":"OAuth is disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"OAuth is disabled (label: `forbidden`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`OAuthClientId` or OAuth client not found (label: `not-found`)\n\nOAuth client not found (label: `not-found`)"}}}},"/oauth/authorization/codes":{"post":{"summary":"Create an OAuth authorization code","description":" [internal route ID: \"create-oauth-auth-code\"]\n\nCurrently only supports the 'code' response type, which corresponds to the authorization code flow.","operationId":"create-oauth-auth-code","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateOAuthAuthorizationCodeRequest_LTQ3NzI1Mjkz"}}},"required":true},"responses":{"201":{"description":"Created","headers":{"Location":{"schema":{"type":"string"}}}},"400":{"content":{"application/json":{"schema":{"example":{"code":400,"label":"redirect-url-miss-match","message":"The redirect URL does not match the one registered with the client"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["redirect-url-miss-match"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"redirect-url-miss-match","message":"The redirect URL does not match the one registered with the client"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["redirect-url-miss-match"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Bad Request\n\nThe redirect URL does not match the one registered with the client (label: `redirect-url-miss-match`) or `body`","headers":{"Location":{"schema":{"type":"string"}}}},"403":{"description":"Forbidden","headers":{"Location":{"schema":{"type":"string"}}}},"404":{"description":"Not Found","headers":{"Location":{"schema":{"type":"string"}}}}}}},"/oauth/token":{"post":{"summary":"Create an OAuth access token","description":" [internal route ID: \"create-oauth-access-token\"]\n\nObtain a new access token from an authorization code or a refresh token.","operationId":"create-oauth-access-token","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Either_OAuthAccessTokenRequest_OAuthRefreshAccessTokenRequest"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OAuthAccessTokenResponse_NzEwOTI4NjQ0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid_grant","message":"Invalid grant"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid_grant","forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid grant (label: `invalid_grant`)\n\nInvalid client credentials (label: `forbidden`)\n\nInvalid grant type (label: `forbidden`)\n\nInvalid refresh token (label: `forbidden`)\n\nOAuth is disabled (label: `forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"OAuth client not found (label: `not-found`)\n\nOAuth authorization code not found (label: `not-found`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"jwt-error","message":"Internal error while handling JWT token"},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["jwt-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Internal error while handling JWT token (label: `jwt-error`)"}}}},"/oauth/revoke":{"post":{"summary":"Revoke an OAuth refresh token","description":" [internal route ID: \"revoke-oauth-refresh-token\"]\n\nRevoke an access token.","operationId":"revoke-oauth-refresh-token","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OAuthRevokeRefreshTokenRequest_MjA1MDg4MzQ4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"forbidden","message":"Invalid refresh token"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid refresh token (label: `forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"OAuth client not found (label: `not-found`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"jwt-error","message":"Internal error while handling JWT token"},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["jwt-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Internal error while handling JWT token (label: `jwt-error`)"}}}},"/oauth/applications":{"get":{"summary":"Get OAuth applications with account access","description":" [internal route ID: \"get-oauth-applications\"]\n\nGet all OAuth applications with active account access for a user.","operationId":"get-oauth-applications","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/OAuthApplication_Mjk5NTUxNjA1"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/OAuthApplication_Mjk5NTUxNjA1"},"type":"array"}}},"description":"OAuth applications found"}}}},"/oauth/applications/{OAuthClientId}/sessions":{"delete":{"summary":"Revoke account access from an OAuth application","description":" [internal route ID: \"revoke-oauth-account-access\"]\n\n","operationId":"revoke-oauth-account-access","parameters":[{"description":"The ID of the OAuth client","in":"path","name":"OAuthClientId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordReqBody_LTcxMzE3ODE3"}}},"required":true},"responses":{"204":{"description":"OAuth application access revoked"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}}},"/oauth/applications/{OAuthClientId}/sessions/{RefreshTokenId}":{"delete":{"summary":"Revoke an active OAuth session","description":" [internal route ID: \"delete-oauth-refresh-token\"]\n\nRevoke an active OAuth session by providing the refresh token ID.","operationId":"delete-oauth-refresh-token","parameters":[{"description":"The ID of the OAuth client","in":"path","name":"OAuthClientId","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"The ID of the refresh token","in":"path","name":"RefreshTokenId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordReqBody_LTcxMzE3ODE3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"OAuth client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`OAuthClientId` or `RefreshTokenId` not found\n\nOAuth client not found (label: `not-found`)"}}}},"/bot/conversations/{conv}":{"post":{"summary":"Add bot","description":" [internal route ID: \"add-bot\"]\n\n","operationId":"add-bot","parameters":[{"in":"path","name":"conv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AddBot_NjI0ODkyODk3"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddBotResponse_ODA5MzA2NTA1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AddBotResponse_ODA5MzA2NTA1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"service-disabled","message":"The desired service is currently disabled."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["service-disabled","too-many-members","invalid-conversation","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The desired service is currently disabled. (label: `service-disabled`)\n\nMaximum number of members per conversation reached. (label: `too-many-members`)\n\nThe operation is not allowed in this conversation. (label: `invalid-conversation`)\n\nAccess denied. (label: `access-denied`)"}}}},"/bot/conversations/{conv}/{bot}":{"delete":{"summary":"Remove bot","description":" [internal route ID: \"remove-bot\"]\n\n","operationId":"remove-bot","parameters":[{"in":"path","name":"conv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"bot","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveBotResponse_LTUxNTQ4MDEy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RemoveBotResponse_LTUxNTQ4MDEy"}}},"description":"User found"},"204":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-conversation","message":"The operation is not allowed in this conversation."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-conversation","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The operation is not allowed in this conversation. (label: `invalid-conversation`)\n\nAccess denied. (label: `access-denied`)"}}}},"/bot/self":{"get":{"summary":"Get self","description":" [internal route ID: \"bot-get-self\"]\n\n","operationId":"bot-get-self","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"User not found (label: `not-found`)"}}},"delete":{"summary":"Delete self","description":" [internal route ID: \"bot-delete-self\"]\n\n","operationId":"bot-delete-self","responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-bot","message":"The targeted user is not a bot."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-bot","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The targeted user is not a bot. (label: `invalid-bot`)\n\nAccess denied. (label: `access-denied`)"}}}},"/bot/client/prekeys":{"get":{"summary":"List prekeys for bot","description":" [internal route ID: \"bot-list-prekeys\"]\n\n","operationId":"bot-list-prekeys","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"maximum":65535,"minimum":0,"type":"integer"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}},"post":{"summary":"Update prekeys for bot","description":" [internal route ID: \"bot-update-prekeys\"]\n\n","operationId":"bot-update-prekeys","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateBotPrekeys_LTg3NzYxODg0"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"client-not-found","message":"Client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["client-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Client not found (label: `client-not-found`)"}}}},"/bot/client":{"get":{"summary":"Get client for bot","description":" [internal route ID: \"bot-get-client\"]\n\n","operationId":"bot-get-client","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Client_MTM1OTcwOTQ1"}}},"description":"Client found"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"client-not-found","message":"Client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["client-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"client-not-found","message":"Client not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["client-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Client not found (label: `client-not-found`)\n\nClient not found (label: `client-not-found`)"}}}},"/bot/users/prekeys":{"post":{"summary":"Claim users prekeys","description":" [internal route ID: \"bot-claim-users-prekeys\"]\n\n","operationId":"bot-claim-users-prekeys","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserClients"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserClientPrekeyMap"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","missing-legalhold-consent-old-clients","too-many-clients","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nToo many clients (label: `too-many-clients`)\n\nAccess denied. (label: `access-denied`)"}}}},"/bot/users":{"get":{"summary":"List users","description":" [internal route ID: \"bot-list-users\"]\n\n","operationId":"bot-list-users","parameters":[{"in":"query","name":"ids","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/BotUserView_LTE2MTkwMTcw"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}}},"/bot/users/{user}/clients":{"get":{"summary":"Get user clients","description":" [internal route ID: \"bot-get-user-clients\"]\n\n","operationId":"bot-get-user-clients","parameters":[{"in":"path","name":"user","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/PubClient"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}}},"/provider/services":{"get":{"summary":"List provider services","description":" [internal route ID: \"get-provider-services\"]\n\n","operationId":"get-provider-services","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Service_MjcyOTA5NjQx"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}},"post":{"summary":"Create a new service","description":" [internal route ID: \"post-provider-services\"]\n\n","operationId":"post-provider-services","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewService_LTYwOTU1MDQ3"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewServiceResponse_LTExMzcwMjg5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewServiceResponse_LTExMzcwMjg5"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-service-key","message":"Invalid service key."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-service-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid service key. (label: `invalid-service-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}}},"/provider/services/{service-id}":{"get":{"summary":"Get provider service by service id","description":" [internal route ID: \"get-provider-services-by-service-id\"]\n\n","operationId":"get-provider-services-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Service_MjcyOTA5NjQx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nService not found. (label: `not-found`)"}}},"put":{"summary":"Update provider service","description":" [internal route ID: \"put-provider-services-by-service-id\"]\n\n","operationId":"put-provider-services-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateService_MjAxNzQ2Njkz"}}},"required":true},"responses":{"200":{"description":"Provider service updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nProvider not found. (label: `not-found`)\n\nService not found. (label: `not-found`)"}}},"delete":{"summary":"Delete service","description":" [internal route ID: \"delete-provider-services-by-service-id\"]\n\n","operationId":"delete-provider-services-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteService_LTY2NzY5NzMz"}}},"required":true},"responses":{"202":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nService not found. (label: `not-found`)"}}}},"/provider/services/{service-id}/connection":{"put":{"summary":"Update provider service connection","description":" [internal route ID: \"put-provider-services-connection-by-service-id\"]\n\n","operationId":"put-provider-services-connection-by-service-id","parameters":[{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateServiceConn_LTQ1OTYwNjIz"}}},"required":true},"responses":{"200":{"description":"Provider service connection updated"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-service-key","message":"Invalid service key."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-service-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid service key. (label: `invalid-service-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`service-id` not found\n\nService not found. (label: `not-found`)"}}}},"/providers/{provider-id}/services":{"get":{"summary":"Get provider services by provider id","description":" [internal route ID: \"get-provider-services-by-provider-id\"]\n\n","operationId":"get-provider-services-by-provider-id","parameters":[{"in":"path","name":"provider-id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/ServiceProfile_LTc2MDQzNTk3"},"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}}},"/services":{"get":{"summary":"List services","description":" [internal route ID: \"get-services\"]\n\n","operationId":"get-services","parameters":[{"in":"query","name":"tags","required":false,"schema":{"enum":["audio","books","business","design","education","entertainment","finance","fitness","food-drink","games","graphics","health","integration","lifestyle","media","medical","movies","music","news","photography","poll","productivity","quiz","rating","shopping","social","sports","travel","tutorial","video","weather"],"type":"string"}},{"in":"query","name":"start","required":false,"schema":{"type":"string"}},{"in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":100,"minimum":10,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceProfilePage_Njg1NDQ5Njc4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}}},"/services/tags":{"get":{"summary":"Get services tags","description":" [internal route ID: \"get-services-tags\"]\n\n","operationId":"get-services-tags","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceTagList"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"}}}},"/providers/{provider-id}/services/{service-id}":{"get":{"summary":"Get provider service by provider id and service id","description":" [internal route ID: \"get-provider-services-by-provider-id-and-service-id\"]\n\n","operationId":"get-provider-services-by-provider-id-and-service-id","parameters":[{"in":"path","name":"provider-id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"service-id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceProfile_LTc2MDQzNTk3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Service not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`provider-id` or `service-id` not found\n\nService not found. (label: `not-found`)"}}}},"/teams/{team-id}/services/whitelisted":{"get":{"summary":"Get whitelisted services by team id","description":" [internal route ID: \"get-whitelisted-services-by-team-id\"]\n\n","operationId":"get-whitelisted-services-by-team-id","parameters":[{"in":"path","name":"team-id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"prefix","required":false,"schema":{"maxLength":128,"minLength":1,"type":"string"}},{"in":"query","name":"filter_disabled","required":false,"schema":{"type":"boolean"}},{"in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":100,"minimum":10,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServiceProfilePage_Njg1NDQ5Njc4"}}},"description":""}}}},"/teams/{team-id}/services/whitelist":{"post":{"summary":"Update service whitelist","description":" [internal route ID: \"post-team-whitelist-by-team-id\"]\n\n","operationId":"post-team-whitelist-by-team-id","parameters":[{"in":"path","name":"team-id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateServiceWhitelist_LTU5MDAwMTIw"}}},"required":true},"responses":{"200":{"description":"UpdateServiceWhitelistRespChanged"},"204":{"description":"UpdateServiceWhitelistRespUnchanged"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-services-not-allowed","message":"Services not allowed in MLS"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-services-not-allowed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Services not allowed in MLS (label: `mls-services-not-allowed`)"}}}},"/provider/register":{"post":{"summary":"Register a new provider","description":" [internal route ID: \"provider-register\"]\n\n","operationId":"provider-register","parameters":[{"in":"header","name":"X-Forwarded-For","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewProvider_LTEyMTY5MjYy"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewProviderResponse_OTE0ODI2NjU0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewProviderResponse_OTE0ODI2NjU0"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `X-Forwarded-For`\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Too many request to generate a verification code."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many request to generate a verification code. (label: `too-many-requests`)"}}}},"/provider/activate":{"get":{"summary":"Activate a provider","description":" [internal route ID: \"provider-activate\"]\n\n","operationId":"provider-activate","parameters":[{"in":"query","name":"key","required":true,"schema":{"type":"string"}},{"in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderActivationResponse_LTgzNTU3MzA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ProviderActivationResponse_LTgzNTU3MzA5"}}},"description":""},"204":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-code","message":"Invalid verification code"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-code","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid verification code (label: `invalid-code`)\n\nAccess denied. (label: `access-denied`)"}}}},"/provider/login":{"post":{"summary":"Login as a provider","description":" [internal route ID: \"provider-login\"]\n\n","operationId":"provider-login","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ProviderLogin_LTE2MTk2NTM5"}}},"required":true},"responses":{"200":{"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"}}}},"/provider/password-reset":{"post":{"summary":"Begin a password reset","description":" [internal route ID: \"provider-password-reset\"]\n\n","operationId":"provider-password-reset","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordReset_LTYzNDYxNTQ3"}}},"required":true},"responses":{"201":{"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-code","message":"Invalid password reset code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-code","invalid-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)\n\nInvalid email or mobile number for password reset. (label: `invalid-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password reset, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ","code-exists"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password reset, new and old password must be different. (label: `password-must-differ`)\n\nA password reset is already in progress. (label: `code-exists`)\n\nA password reset is already in progress. (label: `code-exists`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Too many request to generate a verification code."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many request to generate a verification code. (label: `too-many-requests`)"}}}},"/provider/password-reset/complete":{"post":{"summary":"Complete a password reset","description":" [internal route ID: \"provider-password-reset-complete\"]\n\n","operationId":"provider-password-reset-complete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CompletePasswordReset_LTYzMDAxNDA1"}}},"required":true},"responses":{"200":{"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-code","message":"Invalid password reset code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","invalid-code","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nInvalid verification code (label: `invalid-code`)\n\nAccess denied. (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password reset, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password reset, new and old password must be different. (label: `password-must-differ`)"}}}},"/provider":{"get":{"summary":"Get account","description":" [internal route ID: \"provider-get-account\"]\n\n","operationId":"provider-get-account","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Access denied."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Access denied. (label: `access-denied`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Provider not found. (label: `not-found`)\n\nProvider not found. (label: `not-found`)"}}},"put":{"summary":"Update a provider","description":" [internal route ID: \"provider-update\"]\n\n","operationId":"provider-update","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateProvider_LTQwMjY4MDgy"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-provider","message":"The provider does not exist."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-provider","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)"}}},"delete":{"summary":"Delete a provider","description":" [internal route ID: \"provider-delete\"]\n\n","operationId":"provider-delete","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DeleteProvider_MzYxMzM3Mjg2"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","invalid-provider","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nThe provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)"}}}},"/provider/email":{"put":{"summary":"Update a provider email","description":" [internal route ID: \"provider-update-email\"]\n\n","operationId":"provider-update-email","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/EmailUpdate_LTYwODE0ODQ5"}}},"required":true},"responses":{"202":{"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-email","message":"Invalid e-mail address."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-provider","message":"The provider does not exist."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-provider","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Too many request to generate a verification code."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many request to generate a verification code. (label: `too-many-requests`)"}}}},"/provider/password":{"put":{"summary":"Update a provider password","description":" [internal route ID: \"provider-update-password\"]\n\n","operationId":"provider-update-password","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PasswordChange_NDI0ODgwNDU0"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-credentials","message":"Authentication failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-credentials","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"password-must-differ","message":"For password reset, new and old password must be different."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["password-must-differ"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"For password reset, new and old password must be different. (label: `password-must-differ`)"}}}},"/providers/{pid}":{"get":{"summary":"Get profile","description":" [internal route ID: \"provider-get-profile\"]\n\n","operationId":"provider-get-profile","parameters":[{"in":"path","name":"pid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Provider_NDIyMzQ3ODIy"}}},"description":""},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Provider not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`pid` or Provider not found. (label: `not-found`)"}}}},"/domain-verification/{domain}/backend":{"post":{"summary":"Update the domain redirect configuration","description":" [internal route ID: \"update-domain-redirect\"]\n\n","operationId":"update-domain-redirect","parameters":[{"in":"header","name":"Authorization","required":true,"schema":{"type":"string"}},{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainRedirectConfig_NTI5NDE5MDQy"}}},"required":true},"responses":{"200":{"description":"Updated"},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}}}},"/get-domain-registration":{"post":{"summary":"Get domain registration configuration by email","description":" [internal route ID: \"get-domain-registration\"]\n\n- `due_to_existing_account`: boolean (optional, only present if `domain_redirect` is `no-registration`)\n- `backend`: object (optional, must be present if `domain_redirect` is `backend`)\n - `config_url`: string (required)\n - `webapp_url`: string (optional)\n- `sso_code`: string (optional, must be present if `domain_redirect` is `sso`)","operationId":"get-domain-registration","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetDomainRegistrationRequest_LTg4NTM1MzM2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainRedirectResponse_V10_LTEyMjI4NTM0"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-domain","message":"Invalid domain"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-domain"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid domain (label: `invalid-domain`)"}}}},"/domain-verification/{domain}/team/challenges/{challengeId}":{"post":{"summary":"Verify a DNS verification challenge for a team","description":" [internal route ID: \"verify-challenge-team\"]\n\n","operationId":"verify-challenge-team","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"challengeId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ChallengeToken_Mzk3NTcwOTM3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainOwnershipToken_NTU0ODc1NDE5"}}},"description":""},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}}}},"/domain-verification/{domain}/authorize-team":{"post":{"summary":"Authorize a team to operate on a verified domain","description":" [internal route ID: \"domain-verification-authorize-team\"]\n\n","operationId":"domain-verification-authorize-team","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainOwnershipToken_NTU0ODc1NDE5"}}},"required":true},"responses":{"200":{"description":"Authorized"},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}}}},"/domain-verification/{domain}/team":{"post":{"summary":"Update the team-invite configuration","description":" [internal route ID: \"update-team-invite\"]\n\n","operationId":"update-team-invite","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamInviteConfig_MTg4Nzk4NzMz"}}},"required":true},"responses":{"200":{"description":"Updated"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}}}},"/teams/{teamId}/registered-domains":{"get":{"summary":"Get all registered domains","description":" [internal route ID: \"get-all-registered-domains\"]\n\n","operationId":"get-all-registered-domains","parameters":[{"in":"path","name":"teamId","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RegisteredDomains_V10_NDYwNzYyMTMy"}}},"description":""}}}},"/teams/{teamId}/registered-domains/{domain}":{"delete":{"summary":"Delete a registered domain","description":" [internal route ID: \"delete-registered-domain\"]\n\n","operationId":"delete-registered-domain","parameters":[{"in":"path","name":"teamId","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted"},"402":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":402,"label":"domain-registration-update-payment-required","message":"Domain registration updated payment required"},"properties":{"code":{"enum":[402],"type":"integer"},"label":{"enum":["domain-registration-update-payment-required"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated payment required (label: `domain-registration-update-payment-required`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-forbidden-for-domain-registration-state","message":"Invalid domain registration state update"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-forbidden-for-domain-registration-state"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid domain registration state update (label: `operation-forbidden-for-domain-registration-state`)"}}}},"/domain-verification/{domain}/challenges":{"post":{"summary":"Get a DNS verification challenge","description":" [internal route ID: \"domain-verification-challenge\"]\n\n","operationId":"domain-verification-challenge","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainVerificationChallenge_NjIwMzA1MjE5"}}},"description":""}}}},"/domain-verification/{domain}/challenges/{challengeId}":{"post":{"summary":"Verify a DNS verification challenge","description":" [internal route ID: \"verify-challenge\"]\n\n","operationId":"verify-challenge","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"challengeId","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ChallengeToken_Mzk3NTcwOTM3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DomainOwnershipToken_NTU0ODc1NDE5"}}},"description":""},"401":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":401,"label":"domain-registration-update-auth-failure","message":"Domain registration updated auth failure"},"properties":{"code":{"enum":[401],"type":"integer"},"label":{"enum":["domain-registration-update-auth-failure"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain registration updated auth failure (label: `domain-registration-update-auth-failure`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"domain-verification-failed","message":"Domain verification failed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["domain-verification-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Domain verification failed (label: `domain-verification-failed`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"challenge-not-found","message":"Challenge not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["challenge-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `challengeId` not found\n\nChallenge not found (label: `challenge-not-found`)"}}}},"/user-groups":{"get":{"summary":"Fetch groups accessible to the logged-in user","description":" [internal route ID: \"get-user-groups\"]\n\n","operationId":"get-user-groups","parameters":[{"description":"Search string","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"in":"query","name":"sort_by","required":false,"schema":{"enum":["name","created_at"],"type":"string"}},{"in":"query","name":"sort_order","required":false,"schema":{"enum":["asc","desc"],"type":"string"}},{"in":"query","name":"page_size","required":false,"schema":{"description":"integer from [1..500]","type":"number"}},{"description":"`name` of the last seen user group, used to get the next page when sorting by name.","in":"query","name":"last_seen_name","required":false,"schema":{"maxLength":4000,"minLength":1,"type":"string"}},{"description":"`created_at` field of the last seen user group, used to get the next page when sorting by created_at.","in":"query","name":"last_seen_created_at","required":false,"schema":{"format":"yyyy-mm-ddThh:MM:ssZ","type":"string"}},{"description":"`id` of the last seen group, used to get the next page. **Must** be sent to get the next page.","in":"query","name":"last_seen_id","required":false,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"include_channels","schema":{"default":false,"type":"boolean"}},{"allowEmptyValue":true,"in":"query","name":"include_member_count","schema":{"default":false,"type":"boolean"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupPage_UserGroup_Const_LTMxNDg5MDAy"}}},"description":""}}},"post":{"description":" [internal route ID: \"create-user-group\"]\n\n","operationId":"create-user-group","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewUserGroup_MzYxODU0OTU1"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nOnly team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"}}}},"/user-groups/{gid}":{"get":{"summary":"Fetch a group accessible to the logged-in user","description":" [internal route ID: \"get-user-group\"]\n\n","operationId":"get-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"include_channels","schema":{"default":false,"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}}},"description":"User Group Found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or User group not found (label: `user-group-not-found`)\n\nUser group not found (label: `user-group-not-found`)"}}},"put":{"description":" [internal route ID: \"update-user-group\"]\n\n","operationId":"update-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupUpdate_MjUyNTA3Mjgy"}}},"required":true},"responses":{"200":{"description":"User added updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}},"delete":{"description":" [internal route ID: \"delete-user-group\"]\n\n","operationId":"delete-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User group deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}}},"/user-groups/{gid}/users/{uid}":{"post":{"description":" [internal route ID: \"add-user-to-group\"]\n\n","operationId":"add-user-to-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User added to group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or `uid` not found\n\nUser group not found (label: `user-group-not-found`)"}}},"delete":{"description":" [internal route ID: \"remove-user-from-group\"]\n\n","operationId":"remove-user-from-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User removed from group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or `uid` not found\n\nUser group not found (label: `user-group-not-found`)"}}}},"/user-groups/{gid}/users":{"put":{"summary":"[STUB] Update user group members. Replaces the users with the given list.","description":" [internal route ID: \"update-user-group-members\"]\n\n","operationId":"update-user-group-members","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateUserGroupMembers_LTg1MzQ2NDY3"}}},"required":true},"responses":{"200":{"description":"User group members updated"}}},"post":{"description":" [internal route ID: \"add-users-to-group-bulk\"]\n\n","operationId":"add-users-to-group-bulk","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupAddUsers_LTgzOTYzNzk0"}}},"required":true},"responses":{"204":{"description":"Users added to group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nOnly team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}}},"/user-groups/{gid}/channels":{"put":{"summary":"Replaces the channels with the given list.","description":" [internal route ID: \"update-user-group-channels\"]\n\n","operationId":"update-user-group-channels","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"append_only","schema":{"default":false,"type":"boolean"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateUserGroupChannels_LTIyMjcwMTMx"}}},"required":true},"responses":{"200":{"description":"User group channels updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}}},"/user-groups/check-name":{"post":{"summary":"[STUB] Check if a user group name is available","description":" [internal route ID: \"check-user-group-name-available\"]\n\n","operationId":"check-user-group-name-available","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CheckUserGroupName_LTg0ODU1OTk1"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserGroupNameAvailability_LTYzMDE1NTk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupNameAvailability_LTYzMDE1NTk4"}}},"description":"OK"}}}},"/teams/{tid}/apps":{"get":{"summary":"Get all apps owned by the given team (not including collaborators)","description":" [internal route ID: \"get-apps\"]\n\n","operationId":"get-apps","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"},"type":"array"}}},"description":""}}},"post":{"summary":"Create a new app","description":" [internal route ID: \"create-app\"]\n\n","operationId":"create-app","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewApp_LTQwODMwMzQ4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreatedApp_LTM3NjUxOTY1"}}},"description":""}}}},"/teams/{tid}/apps/{app}":{"put":{"summary":"Update metadata of an existing app","description":" [internal route ID: \"put-app\"]\n\n","operationId":"put-app","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"app","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PutApp_LTE4MDc1OTM4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""}}}},"/teams/{tid}/apps/{app}/cookies":{"post":{"summary":"Get a new app authentication token","description":" [internal route ID: \"refresh-app-cookie\"]\n\n","operationId":"refresh-app-cookie","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"app","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RefreshAppCookieRequest_MjEyMDMyMTk5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RefreshAppCookieResponse_LTQ0MjU1NTIw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-auth","message":"Re-authentication via password required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-auth"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Re-authentication via password required (label: `missing-auth`)"}}}},"/conversations/{cnv_domain}/{cnv}":{"get":{"summary":"Get a conversation by ID","description":" [internal route ID: \"get-conversation\"]\n\n","operationId":"get-conversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Conversation_GroupConvType_MzQzMTQ1OTg3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv}/roles":{"get":{"summary":"Get existing roles available for the given conversation","description":" [internal route ID: \"get-conversation-roles\"]\n\n","operationId":"get-conversation-roles","parameters":[{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationRolesList"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/groupinfo":{"get":{"summary":"Get MLS group information","description":" [internal route ID: \"get-group-info\"]\n\n","operationId":"get-group-info","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/GroupInfoData"}}},"description":"The group information"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-missing-group-info","message":"The conversation has no group information"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-missing-group-info","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nThe conversation has no group information (label: `mls-missing-group-info`)\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/list-ids":{"post":{"summary":"Get all conversation IDs.","description":" [internal route ID: \"list-conversation-ids\"]\n\nThe IDs returned by this endpoint are paginated. To get the first page, make a call with the `paging_state` field set to `null` (or omitted). Whenever the `has_more` field of the response is set to `true`, more results are available, and they can be obtained by calling the endpoint again, but this time passing the value of `paging_state` returned by the previous call. One can continue in this fashion until all results are returned, which is indicated by `has_more` being `false`. Note that `paging_state` should be considered an opaque token. It should not be inspected, or stored, or reused across multiple unrelated invocations of the endpoint.","operationId":"list-conversation-ids","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetMuliTabPgRqs_ConvrIdLcOm10_ODU0NjQ1NDMz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MultiTabePg_ConvrsIdqf_cLORm_Q_Mzg0MjYzNzM0"}}},"description":""}}}},"/conversations/list":{"post":{"summary":"Get conversation metadata for a list of conversation ids","description":" [internal route ID: \"list-conversations\"]\n\n","operationId":"list-conversations","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ListConversations_MjkxMTIwODMz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationsResponse_GroupConvType_ODkxMjM2ODM0"}}},"description":""}}}},"/conversations/join":{"get":{"summary":"Get limited conversation information by key/code pair","description":" [internal route ID: \"get-conversation-by-reusable-code\"]\n\n","operationId":"get-conversation-by-reusable-code","parameters":[{"in":"query","name":"key","required":true,"schema":{"type":"string"}},{"in":"query","name":"code","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCoverView_LTMwNDkxMTA1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","access-denied","invalid-conversation-password"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid conversation password (label: `invalid-conversation-password`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"guest-links-disabled","message":"The guest link feature is disabled and all guest links have been revoked"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}}},"post":{"summary":"Join a conversation using a reusable code","description":" [internal route ID: \"join-conversation-by-code-unqualified\"]\n\nIf the guest links team feature is disabled, this will fail with 409 GuestLinksDisabled.Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/code-check` which responds with 404 CodeNotFound if guest links are disabled.","operationId":"join-conversation-by-code-unqualified","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/JoinConversationByCode_NjgzMzM4Mjg5"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation joined"},"204":{"description":"Conversation unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"too-many-members","message":"Maximum number of members per conversation reached"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["too-many-members","no-team-member","invalid-op","access-denied","invalid-conversation-password"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Maximum number of members per conversation reached (label: `too-many-members`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid conversation password (label: `invalid-conversation-password`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"guest-links-disabled","message":"The guest link feature is disabled and all guest links have been revoked"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}}}},"/conversations":{"post":{"summary":"Create a new conversation","description":" [internal route ID: \"create-group-conversation\"]\n\nThis returns 201 when a new conversation is created, and 200 when the conversation already existed\nOAuth scope: `write:conversations`","operationId":"create-group-conversation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewConv_LTgzNTk1NDQx"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGroupConversation_GroupConvType_MTQ3OTc4NDk3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateGroupConversation_GroupConvType_MTQ3OTc4NDk3"}}},"description":"Conversation created","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"history-not-supported","message":"Shared history is not supported on this conversation"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["history-not-supported","mls-not-enabled","non-empty-member-list"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nShared history is not supported on this conversation (label: `history-not-supported`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nAttempting to add group members outside MLS (label: `non-empty-member-list`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"channels-not-enabled","message":"The channels feature is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["channels-not-enabled","not-mls-conversation","missing-legalhold-consent","operation-denied","no-team-member","not-connected","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The channels feature is not enabled for this team (label: `channels-not-enabled`)\n\nThis operation requires an MLS conversation (label: `not-mls-conversation`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nUsers are not connected (label: `not-connected`)\n\nConversation access denied (label: `access-denied`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}}},"/conversations/self":{"post":{"summary":"Create a self-conversation","description":" [internal route ID: \"create-self-conversation\"]\n\n","operationId":"create-self-conversation","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_GroupConvType_LTU2MzYxNTg0V6"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_GroupConvType_LTU2MzYxNTg0V6"}}},"description":"Conversation existed","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_GroupConvType_LTU2MzYxNTg0V6"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v6_OwnConversation_GroupConvType_LTU2MzYxNTg0V6"}}},"description":"Conversation created","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}}}}},"/conversations/mls-self":{"get":{"summary":"Get the user's MLS self-conversation","description":" [internal route ID: \"get-mls-self-conversation\"]\n\n","operationId":"get-mls-self-conversation","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v9_OwnConversation_GroupConvType_LTU2MzYxNTg0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v9_OwnConversation_GroupConvType_LTU2MzYxNTg0"}}},"description":"The MLS self-conversation"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"}}}},"/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}":{"get":{"summary":"Get information about an MLS subconversation","description":" [internal route ID: \"get-subconversation\"]\n\n","operationId":"get-subconversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicSubConversation_MjI2NTIxMzU4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PublicSubConversation_MjI2NTIxMzU4"}}},"description":"Subconversation"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-subconv-unsupported-convtype","message":"MLS subconversations are only supported for regular conversations"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-subconv-unsupported-convtype","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS subconversations are only supported for regular conversations (label: `mls-subconv-unsupported-convtype`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)"}}},"delete":{"summary":"Delete an MLS subconversation","description":" [internal route ID: \"delete-subconversation\"]\n\n","operationId":"delete-subconversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSReset_NzgwODA3ODc4"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}},"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":"Deletion successful"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-stale-message","message":"The conversation epoch in a message is too old"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-stale-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The conversation epoch in a message is too old (label: `mls-stale-message`)"}}}},"/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}/self":{"delete":{"summary":"Leave an MLS subconversation","description":" [internal route ID: \"leave-subconversation\"]\n\n","operationId":"leave-subconversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled","mls-protocol-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nMLS protocol error (label: `mls-protocol-error`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-stale-message","message":"The conversation epoch in a message is too old"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-stale-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The conversation epoch in a message is too old (label: `mls-stale-message`)"}}}},"/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}/groupinfo":{"get":{"summary":"Get MLS group information of subconversation","description":" [internal route ID: \"get-subconversation-group-info\"]\n\n","operationId":"get-subconversation-group-info","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"subconv","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/GroupInfoData"}}},"description":"The group information"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-missing-group-info","message":"The conversation has no group information"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-missing-group-info","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `subconv` not found\n\nThe conversation has no group information (label: `mls-missing-group-info`)\n\nConversation not found (label: `no-conversation`)"}}}},"/one2one-conversations":{"post":{"summary":"Create a 1:1 conversation","description":" [internal route ID: \"create-one-to-one-conversation\"]\n\n","operationId":"create-one-to-one-conversation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOne2OneConv_LTI3OTc4NDAz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_GroupConvType_LTU2MzYxNTg0V3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_GroupConvType_LTU2MzYxNTg0V3"}}},"description":"Conversation existed","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_GroupConvType_LTU2MzYxNTg0V3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/v3_OwnConversation_GroupConvType_LTU2MzYxNTg0V3"}}},"description":"Conversation created","headers":{"Location":{"description":"Conversation ID","schema":{"format":"uuid","type":"string"}}}},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","operation-denied","not-connected","no-team-member","non-binding-team-members","invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nInsufficient permissions (label: `operation-denied`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nBoth users must be members of the same binding team (label: `non-binding-team-members`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","non-binding-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)\n\nNot a member of a binding team (label: `non-binding-team`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}}},"/one2one-conversations/{usr_domain}/{usr}":{"get":{"summary":"Get an MLS 1:1 conversation","description":" [internal route ID: \"get-one-to-one-mls-conversation\"]\n\n","operationId":"get-one-to-one-mls-conversation","parameters":[{"in":"path","name":"usr_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"usr","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"format","required":false,"schema":{"enum":["raw","jwk"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSOne2OneConversation_SomeKey_GroupConvType_LTg2MTcwMjY3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSOne2OneConversation_SomeKey_GroupConvType_LTg2MTcwMjY3"}}},"description":"MLS 1-1 conversation"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `format`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"not-connected","message":"Users are not connected"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["not-connected"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Users are not connected (label: `not-connected`)"}}}},"/conversations/{cnv_domain}/{cnv}/members":{"put":{"summary":"Replace the members of a conversation.","description":" [internal route ID: \"replace-members-in-conversation\"]\n\nThis will add any members not already in the conversation, and remove any members not in the provided list except users that are associated via a user group. The given role in the request body will be applied to all added members. The roles of already existing members will not be changed even if these members are included in the request body and their role differs from the role provided in this request.","operationId":"replace-members-in-conversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InviteQualified_ODYyODIyNjYz"}}},"required":true},"responses":{"200":{"description":"Conversation members replaced"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-group-id-not-supported","message":"The group ID version of the conversation is not supported by one of the federated backends"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-group-id-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","not-connected","no-team-member","access-denied","too-many-members","invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nMaximum number of members per conversation reached (label: `too-many-members`)\n\nInvalid operation (label: `invalid-op`)\n\nThe conversation would be left without an admin\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nInsufficient authorization (missing add_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}},"post":{"summary":"Add qualified members to an existing conversation.","description":" [internal route ID: \"add-members-to-conversation\"]\n\n","operationId":"add-members-to-conversation","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InviteQualified_ODYyODIyNjYz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation updated"},"204":{"description":"Conversation unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-group-id-not-supported","message":"The group ID version of the conversation is not supported by one of the federated backends"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-group-id-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"missing-legalhold-consent","message":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["missing-legalhold-consent","not-connected","no-team-member","access-denied","too-many-members","invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nMaximum number of members per conversation reached (label: `too-many-members`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInsufficient authorization (missing add_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}}},"/conversations/code-check":{"post":{"summary":"Check validity of a conversation code.","description":" [internal route ID: \"code-check\"]\n\nIf the guest links team feature is disabled, this will fail with 404 CodeNotFound.Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/join` which responds with 409 GuestLinksDisabled if guest links are disabled.","operationId":"code-check","parameters":[{"in":"header","name":"X-Forwarded-For","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCode_Mjg3OTI1NTMx"}}},"required":true},"responses":{"200":{"description":"Valid"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-conversation-password","message":"Invalid conversation password"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-conversation-password"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid conversation password (label: `invalid-conversation-password`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"}}}},"/conversations/{cnv}/code":{"get":{"summary":"Get existing conversation code","description":" [internal route ID: \"get-code\"]\n\n\nOAuth scope: `write:conversations_code`","operationId":"get-code","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}}},"description":"Conversation Code"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","no-conversation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"guest-links-disabled","message":"The guest link feature is disabled and all guest links have been revoked"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}}},"post":{"summary":"Create or recreate a conversation code","description":" [internal route ID: \"create-conversation-code-unqualified\"]\n\n\nOAuth scope: `write:conversations_code`","operationId":"create-conversation-code-unqualified","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateConversationCodeRequest_NTYzMTA1NDYz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationCodeInfo_LTc5MzgzNjg3"}}},"description":"Conversation code already exists."},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation code created."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"create-conv-code-conflict","message":"Conversation code already exists with a different password setting than the requested one."},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["create-conv-code-conflict","guest-links-disabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation code already exists with a different password setting than the requested one. (label: `create-conv-code-conflict`)\n\nThe guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)"}}},"delete":{"summary":"Delete conversation code","description":" [internal route ID: \"remove-code-unqualified\"]\n\n","operationId":"remove-code-unqualified","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation code deleted."},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv}/features/conversationGuestLinks":{"get":{"summary":"Get the status of the guest links feature for a conversation that potentially has been created by someone from another team.","description":" [internal route ID: \"get-conversation-guest-links-status\"]\n\n","operationId":"get-conversation-guest-links-status","parameters":[{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"Conversation access denied"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/typing":{"post":{"summary":"Sending typing notifications","description":" [internal route ID: \"member-typing-qualified\"]\n\n","operationId":"member-typing-qualified","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TypingStatus_LTg5MzcyNDMy"}}},"required":true},"responses":{"200":{"description":"Notification sent"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}":{"put":{"summary":"Update membership of the specified user","description":" [internal route ID: \"update-other-member\"]\n\n**Note**: at least one field has to be provided.","operationId":"update-other-member","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"usr_domain","required":true,"schema":{"type":"string"}},{"description":"Target User ID","in":"path","name":"usr","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/OtherMemberUpdate_LTM1MjYzOTU0"}}},"required":true},"responses":{"200":{"description":"Membership updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nInvalid target (label: `invalid-op`)\n\nInsufficient authorization (missing modify_other_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation-member","message":"Conversation member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation-member","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `usr_domain` or `usr` not found\n\nConversation member not found (label: `no-conversation-member`)\n\nConversation not found (label: `no-conversation`)"}}},"delete":{"summary":"Remove a member from a conversation","description":" [internal route ID: \"remove-member\"]\n\n","operationId":"remove-member","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"usr_domain","required":true,"schema":{"type":"string"}},{"description":"Target User ID","in":"path","name":"usr","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Member removed"},"204":{"description":"No change"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"eligible_members":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["eligible_members"],"type":"object"}}},"description":"The conversation would be left without an admin\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or `usr_domain` or `usr` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/name":{"put":{"summary":"Update conversation name","description":" [internal route ID: \"update-conversation-name\"]\n\n","operationId":"update-conversation-name","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationRename_ODkwODg1MzQ0"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Name unchanged"},"204":{"description":"Name updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_conversation_name) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/message-timer":{"put":{"summary":"Update the message timer for a conversation","description":" [internal route ID: \"update-conversation-message-timer\"]\n\n","operationId":"update-conversation-message-timer","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationMessageTimerUpdate_LTcxMjUwNzQ4"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Message timer updated"},"204":{"description":"Message timer unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_message_timer) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/receipt-mode":{"put":{"summary":"Update receipt mode for a conversation","description":" [internal route ID: \"update-conversation-receipt-mode\"]\n\n","operationId":"update-conversation-receipt-mode","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationReceiptModeUpdate_NDE4MzUzNTU3"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Receipt mode updated"},"204":{"description":"Receipt mode unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-receipts-not-allowed","message":"Read receipts on MLS conversations are not allowed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-receipts-not-allowed","invalid-op","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Read receipts on MLS conversations are not allowed (label: `mls-receipts-not-allowed`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_receipt_mode) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/access":{"put":{"summary":"Update access modes for a conversation","description":" [internal route ID: \"update-conversation-access\"]\n\n","operationId":"update-conversation-access","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationAccessData_MjMxMTI5ODc3"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Access updated"},"204":{"description":"Access unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid target access"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid target access (label: `invalid-op`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nInsufficient authorization (missing modify_conversation_access) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/history":{"put":{"summary":"Update history settings of a conversation","description":" [internal route ID: \"update-conversation-history\"]\n\n","operationId":"update-conversation-history","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationHistoryUpdate_LTg5MDQ5Nzgx"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"History updated"},"204":{"description":"History unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"history-not-supported","message":"Shared history is not supported on this conversation"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["history-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nShared history is not supported on this conversation (label: `history-not-supported`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"action-denied","message":"Insufficient authorization (missing modify_conversation_access)"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["action-denied","invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient authorization (missing modify_conversation_access) (label: `action-denied`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/self":{"get":{"summary":"Get self membership properties","description":" [internal route ID: \"get-conversation-self\"]\n\n","operationId":"get-conversation-self","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Member_OTA5OTgyNzcw"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}},"put":{"summary":"Update self membership properties","description":" [internal route ID: \"update-conversation-self\"]\n\n**Note**: at least one field has to be provided.","operationId":"update-conversation-self","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MemberUpdate_LTg4NTQ0OTYz"}}},"required":true},"responses":{"200":{"description":"Update successful"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/protocol":{"put":{"summary":"Update the protocol of the conversation","description":" [internal route ID: \"update-conversation-protocol\"]\n\n**Note**: Only proteus->mixed upgrade is supported.","operationId":"update-conversation-protocol","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ProtocolUpdate_NzY1ODgxNDQy"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Conversation updated"},"204":{"description":"Conversation unchanged"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-migration-criteria-not-satisfied","message":"The migration criteria for mixed to MLS protocol transition are not satisfied for this conversation"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-migration-criteria-not-satisfied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nThe migration criteria for mixed to MLS protocol transition are not satisfied for this conversation (label: `mls-migration-criteria-not-satisfied`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","invalid-op","action-denied","invalid-protocol-transition"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nProtocol transition is invalid (label: `invalid-protocol-transition`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nTeam not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv_domain}/{cnv}/add-permission":{"put":{"summary":"Update the permissions for adding members to a channel","description":" [internal route ID: \"update-channel-add-permission\"]\n\n","operationId":"update-channel-add-permission","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"description":"Conversation ID","in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/AddPermissionUpdate_LTU3MzEwOTY4"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}}},"description":"Add permissions updated"},"204":{"description":"Add permissions unchanged"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid target access"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","not-connected","operation-denied","no-team-member","access-denied","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid target access (label: `invalid-op`)\n\nUsers are not connected (label: `not-connected`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_add_permissions) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` not found\n\nTeam not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"non_federating_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["non_federating_backends"],"type":"object"}}},"description":"Adding members to the conversation is not possible because the backends involved do not form a fully connected graph"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}}},"/teams/{tid}/conversations/roles":{"get":{"summary":"Get existing roles available for the given team","description":" [internal route ID: \"get-team-conversation-roles\"]\n\n","operationId":"get-team-conversation-roles","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationRolesList"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"}}}},"/teams/{tid}/conversations":{"get":{"summary":"Get team conversations","description":" [internal route ID: \"get-team-conversations\"]\n\n","operationId":"get-team-conversations","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamConversationList_OTI3MzY3NzY0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"}}}},"/teams/{tid}/conversations/{cid}":{"get":{"summary":"Get one team conversation","description":" [internal route ID: \"get-team-conversation\"]\n\n","operationId":"get-team-conversation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"cid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamConversation_LTIwNzgyNTEz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `cid` not found\n\nConversation not found (label: `no-conversation`)"}}},"delete":{"summary":"Remove a team conversation","description":" [internal route ID: \"delete-team-conversation\"]\n\n","operationId":"delete-team-conversation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"cid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"Conversation deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing delete_conversation) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `cid` not found\n\nConversation not found (label: `no-conversation`)"}}}},"/conversations/{cnv}/otr/messages":{"post":{"summary":"Post an encrypted message to a conversation (accepts JSON or Protobuf)","description":" [internal route ID: \"post-otr-message-unqualified\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts two query params:\n- `ignore_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are ignored.\n - When 'false' all missing clients are reported.\n - When comma separated list of user-ids, only clients for listed users are ignored.\n- `report_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are reported.\n - When 'false' all missing clients are ignored.\n - When comma separated list of user-ids, only clients for listed users are reported.\n\nApart from these, the request body also accepts `report_missing` which can only be a list of user ids and behaves the same way as the query parameter.\n\nAll three of these should be considered mutually exclusive. The server however does not error if more than one is specified, it reads them in this order of precedence:\n- `report_missing` in the request body has highest precedence.\n- `ignore_missing` in the query param is the next.\n- `report_missing` in the query param has the lowest precedence.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-otr-message-unqualified","parameters":[{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"ignore_missing","required":false,"schema":{"type":"string"}},{"in":"query","name":"report_missing","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}},"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Message sent"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv` or Conversation not found (label: `no-conversation`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Missing clients"}}}},"/broadcast/otr/messages":{"post":{"summary":"Broadcast an encrypted message to all team members and all contacts (accepts JSON or Protobuf)","description":" [internal route ID: \"post-otr-broadcast-unqualified\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts two query params:\n- `ignore_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are ignored.\n - When 'false' all missing clients are reported.\n - When comma separated list of user-ids, only clients for listed users are ignored.\n- `report_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are reported.\n - When 'false' all missing clients are ignored.\n - When comma separated list of user-ids, only clients for listed users are reported.\n\nApart from these, the request body also accepts `report_missing` which can only be a list of user ids and behaves the same way as the query parameter.\n\nAll three of these should be considered mutually exclusive. The server however does not error if more than one is specified, it reads them in this order of precedence:\n- `report_missing` in the request body has highest precedence.\n- `ignore_missing` in the query param is the next.\n- `report_missing` in the query param has the lowest precedence.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-otr-broadcast-unqualified","parameters":[{"in":"query","name":"ignore_missing","required":false,"schema":{"type":"string"}},{"in":"query","name":"report_missing","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}},"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Message sent"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"too-many-users-to-broadcast","message":"Too many users to fan out the broadcast event to"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["too-many-users-to-broadcast"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `report_missing` or `ignore_missing`\n\nToo many users to fan out the broadcast event to (label: `too-many-users-to-broadcast`)"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","non-binding-team","no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nNot a member of a binding team (label: `non-binding-team`)\n\nTeam not found (label: `no-team`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Missing clients"}}}},"/conversations/{cnv_domain}/{cnv}/proteus/messages":{"post":{"summary":"Post an encrypted message to a conversation (accepts only Protobuf)","description":" [internal route ID: \"post-proteus-message\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts `client_mismatch_strategy` in the body. It can have these values:\n- `report_all`: When set, the message is not sent if any clients are missing. The missing clients are reported in the response.\n- `ignore_all`: When set, no checks about missing clients are carried out.\n- `report_only`: Takes a list of qualified UserIDs. If any clients of the listed users are missing, the message is not sent. The missing clients are reported in the response.\n- `ignore_only`: Takes a list of qualified UserIDs. If any clients of the non-listed users are missing, the message is not sent. The missing clients are reported in the response.\n\nThe sending of messages in a federated conversation could theoretically fail partially. To make this case unlikely, the backend first gets a list of clients from all the involved backends and then tries to send a message. So, if any backend is down, the message is not propagated to anyone. But the actual message fan out to multiple backends could still fail partially. This type of failure is reported as a 201, the clients for which the message sending failed are part of the response body.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-proteus-message","parameters":[{"in":"path","name":"cnv_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"cnv","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/QualifiedNewOtrMessage"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Message sent"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`cnv_domain` or `cnv` or Conversation not found (label: `no-conversation`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Missing clients"}}}},"/broadcast/proteus/messages":{"post":{"summary":"Post an encrypted message to all team members and all contacts (accepts only Protobuf)","description":" [internal route ID: \"post-proteus-broadcast\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts `client_mismatch_strategy` in the body. It can have these values:\n- `report_all`: When set, the message is not sent if any clients are missing. The missing clients are reported in the response.\n- `ignore_all`: When set, no checks about missing clients are carried out.\n- `report_only`: Takes a list of qualified UserIDs. If any clients of the listed users are missing, the message is not sent. The missing clients are reported in the response.\n- `ignore_only`: Takes a list of qualified UserIDs. If any clients of the non-listed users are missing, the message is not sent. The missing clients are reported in the response.\n\nThe sending of messages in a federated conversation could theoretically fail partially. To make this case unlikely, the backend first gets a list of clients from all the involved backends and then tries to send a message. So, if any backend is down, the message is not propagated to anyone. But the actual message fan out to multiple backends could still fail partially. This type of failure is reported as a 201, the clients for which the message sending failed are part of the response body.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.","operationId":"post-proteus-broadcast","requestBody":{"content":{"application/x-protobuf":{"schema":{"$ref":"#/components/schemas/QualifiedNewOtrMessage"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Message sent"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"too-many-users-to-broadcast","message":"Too many users to fan out the broadcast event to"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["too-many-users-to-broadcast"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nToo many users to fan out the broadcast event to (label: `too-many-users-to-broadcast`)"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation","non-binding-team","no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nNot a member of a binding team (label: `non-binding-team`)\n\nTeam not found (label: `no-team`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MessageSendingStatus_ODg0NDgyNDk4"}}},"description":"Missing clients"}}}},"/bot/messages":{"post":{"description":" [internal route ID: \"post-bot-message-unqualified\"]\n\n","operationId":"post-bot-message-unqualified","parameters":[{"in":"query","name":"ignore_missing","required":false,"schema":{"type":"string"}},{"in":"query","name":"report_missing","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewOtrMessage_LTUyMTE5MTMw"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Message sent"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unknown-client","message":"Unknown Client"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unknown-client","missing-legalhold-consent-old-clients","missing-legalhold-consent"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)\n\nConversation not found (label: `no-conversation`)"},"412":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientMismatch_ODUyODM0MDQ0"}}},"description":"Missing clients"}}}},"/bot/conversation":{"get":{"description":" [internal route ID: \"get-bot-conversation\"]\n\n","operationId":"get-bot-conversation","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/BotConvView_LTYzMjIzMjQz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)"}}}},"/teams/{tid}":{"get":{"summary":"Get a team by ID","description":" [internal route ID: \"get-team\"]\n\n","operationId":"get-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Team_NDg4MjQwOTIw"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Update team properties","description":" [internal route ID: \"update-team\"]\n\n","operationId":"update-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamUpdateData_LTE0NTM2NTU5"}}},"required":true},"responses":{"200":{"description":"Team updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions (missing SetTeamData)"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (missing SetTeamData) (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}}},"delete":{"summary":"Delete a team","description":" [internal route ID: \"delete-team\"]\n\n","operationId":"delete-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamDeleteData_ODI5NTU0ODE5"}}},"required":true},"responses":{"202":{"description":"Team is scheduled for removal"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Verification code required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed","access-denied","operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Verification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (missing DeleteTeam) (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"503":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":503,"label":"queue-full","message":"The delete queue is full; no further delete requests can be processed at the moment"},"properties":{"code":{"enum":[503],"type":"integer"},"label":{"enum":["queue-full"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The delete queue is full; no further delete requests can be processed at the moment (label: `queue-full`)"}}}},"/teams/{tid}/channels/search":{"get":{"summary":"Search channels","description":" [internal route ID: \"search-channels\"]\n\n","operationId":"search-channels","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Search string","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"in":"query","name":"sort_order","required":false,"schema":{"enum":["asc","desc"],"type":"string"}},{"in":"query","name":"page_size","required":false,"schema":{"description":"integer from [1..500]","type":"number"}},{"description":"`name` of the last seen channel of the current page, used to get the next page.","in":"query","name":"last_seen_name","required":false,"schema":{"type":"string"}},{"description":"`id` of the last seen channel, used to get the next page, used as a tie breaker. **Must** be sent to get the next page.","in":"query","name":"last_seen_id","required":false,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"discoverable","schema":{"default":false,"type":"boolean"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ConversationPage_LTIwMDU2NDI3"}}},"description":""}}}},"/teams/{tid}/features/sso":{"get":{"summary":"Get config for sso","description":" [internal route ID: (\"get\", SSOConfig)]\n\n","operationId":"get_SSOConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SSOConfig_NjcyMjU4MDY2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/legalhold":{"get":{"summary":"Get config for legalhold","description":" [internal route ID: (\"get\", LegalholdConfig)]\n\n","operationId":"get_LegalholdConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_LegalholdConfig_LTc5MTk5OTIw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for legalhold","description":" [internal route ID: (\"put\", LegalholdConfig)]\n\n","operationId":"put_LegalholdConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_LegalholdConfig_NjM3MTkxNjYw"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_LegalholdConfig_LTc5MTk5OTIw"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-disable-unimplemented","message":"legal hold cannot be disabled for whitelisted teams"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-disable-unimplemented","legalhold-not-enabled","too-large-team-for-legalhold","action-denied","no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nCannot enable legalhold on large teams (reason: for removing LH from team, we need to iterate over all members, which is only supported for teams with less than 2k members) (label: `too-large-team-for-legalhold`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}}}},"/teams/{tid}/features/searchVisibility":{"get":{"summary":"Get config for searchVisibility","description":" [internal route ID: (\"get\", SearchVisibilityAvailableConfig)]\n\n","operationId":"get_SearchVisibilityAvailableConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for searchVisibility","description":" [internal route ID: (\"put\", SearchVisibilityAvailableConfig)]\n\n","operationId":"put_SearchVisibilityAvailableConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_SearchVisibilityAvailableConfig_LTMzNTkxODI1"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/search-visibility":{"get":{"summary":"Shows the value for search visibility","description":" [internal route ID: \"get-search-visibility\"]\n\n","operationId":"get-search-visibility","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSearchVisibilityView_Mzg3MzMzMTk3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}}},"put":{"summary":"Sets the search visibility for the whole team","description":" [internal route ID: \"set-search-visibility\"]\n\n","operationId":"set-search-visibility","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSearchVisibilityView_Mzg3MzMzMTk3"}}},"required":true},"responses":{"204":{"description":"Search visibility set"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"team-search-visibility-not-enabled","message":"Custom search is not available for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["team-search-visibility-not-enabled","operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Custom search is not available for this team (label: `team-search-visibility-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/validateSAMLemails":{"get":{"summary":"Get config for validateSAMLemails","description":" [internal route ID: (\"get\", RequireExternalEmailVerificationConfig)]\n\n

Controls whether externally managed email addresses (from SAML or SCIM) must be verified by the user, or are auto-activated.

The external feature name is kept as validateSAMLemails for backward compatibility. That name is misleading because the feature also applies to SCIM-managed users, and it controls email ownership verification rather than generic email validation.

","operationId":"get_RequireExternalEmailVerificationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_RqiExnmVfCg_NjUyMzgzNzY5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/digitalSignatures":{"get":{"summary":"Get config for digitalSignatures","description":" [internal route ID: (\"get\", DigitalSignaturesConfig)]\n\n","operationId":"get_DigitalSignaturesConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_DigitalSignaturesConfig_ODM2MDA2NDU4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/appLock":{"get":{"summary":"Get config for appLock","description":" [internal route ID: (\"get\", AppLockConfigB)]\n\n","operationId":"get_AppLockConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for appLock","description":" [internal route ID: (\"put\", AppLockConfigB)]\n\n","operationId":"put_AppLockConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_AppLockConfigB_Bare_Identity_MTAzMjI5NDYy"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/fileSharing":{"get":{"summary":"Get config for fileSharing","description":" [internal route ID: (\"get\", FileSharingConfig)]\n\n","operationId":"get_FileSharingConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_FileSharingConfig_MjgwNjIzODEz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for fileSharing","description":" [internal route ID: (\"put\", FileSharingConfig)]\n\n","operationId":"put_FileSharingConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_FileSharingConfig_LTUyNjkxMzM4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_FileSharingConfig_MjgwNjIzODEz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/classifiedDomains":{"get":{"summary":"Get config for classifiedDomains","description":" [internal route ID: (\"get\", ClassifiedDomainsConfig)]\n\n","operationId":"get_ClassifiedDomainsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ClassifiedDomainsConfig_LTY1ODQwODg1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/conferenceCalling":{"get":{"summary":"Get config for conferenceCalling","description":" [internal route ID: (\"get\", ConferenceCallingConfigB)]\n\n","operationId":"get_ConferenceCallingConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_CnfigBIdy_NzY1NDU5MDAy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for conferenceCalling","description":" [internal route ID: (\"put\", ConferenceCallingConfigB)]\n\n","operationId":"put_ConferenceCallingConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_ConferenceCallingConfigB_Bare_Identity_Njc2NTcxNTI3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_CnfigBIdy_NzY1NDU5MDAy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/selfDeletingMessages":{"get":{"summary":"Get config for selfDeletingMessages","description":" [internal route ID: (\"get\", SelfDeletingMessagesConfigB)]\n\n","operationId":"get_SelfDeletingMessagesConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for selfDeletingMessages","description":" [internal route ID: (\"put\", SelfDeletingMessagesConfigB)]\n\n","operationId":"put_SelfDeletingMessagesConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Featur_SlfDingMsCoBIdy_LTg2MzYzNjc2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/conversationGuestLinks":{"get":{"summary":"Get config for conversationGuestLinks","description":" [internal route ID: (\"get\", GuestLinksConfig)]\n\n","operationId":"get_GuestLinksConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for conversationGuestLinks","description":" [internal route ID: (\"put\", GuestLinksConfig)]\n\n","operationId":"put_GuestLinksConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_GuestLinksConfig_NjQyMDMxNjg3"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/sndFactorPasswordChallenge":{"get":{"summary":"Get config for sndFactorPasswordChallenge","description":" [internal route ID: (\"get\", SndFactorPasswordChallengeConfig)]\n\n","operationId":"get_SndFactorPasswordChallengeConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for sndFactorPasswordChallenge","description":" [internal route ID: (\"put\", SndFactorPasswordChallengeConfig)]\n\n","operationId":"put_SndFactorPasswordChallengeConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_SndFactorPasswordChallengeConfig_NDc0MzUyMzQz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/mls":{"get":{"summary":"Get config for mls","description":" [internal route ID: (\"get\", MLSConfigB)]\n\n","operationId":"get_MLSConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for mls","description":" [internal route ID: (\"put\", MLSConfigB)]\n\n","operationId":"put_MLSConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MLSConfigB_Bare_Identity_LTI5MjA3MDYy"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/exposeInvitationURLsToTeamAdmin":{"get":{"summary":"Get config for exposeInvitationURLsToTeamAdmin","description":" [internal route ID: (\"get\", ExposeInvitationURLsToTeamAdminConfig)]\n\n","operationId":"get_ExposeInvitationURLsToTeamAdminConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for exposeInvitationURLsToTeamAdmin","description":" [internal route ID: (\"put\", ExposeInvitationURLsToTeamAdminConfig)]\n\n","operationId":"put_ExposeInvitationURLsToTeamAdminConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_ExposeInvitationURLsToTeamAdminConfig_LTY5NzY2Mzg5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/searchVisibilityInbound":{"get":{"summary":"Get config for searchVisibilityInbound","description":" [internal route ID: (\"get\", SearchVisibilityInboundConfig)]\n\n","operationId":"get_SearchVisibilityInboundConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for searchVisibilityInbound","description":" [internal route ID: (\"put\", SearchVisibilityInboundConfig)]\n\n","operationId":"put_SearchVisibilityInboundConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_SearchVisibilityInboundConfig_MTI1NzQxODY2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/outlookCalIntegration":{"get":{"summary":"Get config for outlookCalIntegration","description":" [internal route ID: (\"get\", OutlookCalIntegrationConfig)]\n\n","operationId":"get_OutlookCalIntegrationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for outlookCalIntegration","description":" [internal route ID: (\"put\", OutlookCalIntegrationConfig)]\n\n","operationId":"put_OutlookCalIntegrationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_OutlookCalIntegrationConfig_LTg0MjIxNTMx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/mlsE2EId":{"get":{"summary":"Get config for mlsE2EId","description":" [internal route ID: (\"get\", MlsE2EIdConfigB)]\n\n","operationId":"get_MlsE2EIdConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for mlsE2EId","description":" [internal route ID: (\"put\", MlsE2EIdConfigB)]\n\n","operationId":"put_MlsE2EIdConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MlsE2EIdConfigB_Bare_Identity_LTUxODYzODEx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/mlsMigration":{"get":{"summary":"Get config for mlsMigration","description":" [internal route ID: (\"get\", MlsMigrationConfigB)]\n\n","operationId":"get_MlsMigrationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_MsignCfBIdy_LTE1NjAxNjU2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for mlsMigration","description":" [internal route ID: (\"put\", MlsMigrationConfigB)]\n\n","operationId":"put_MlsMigrationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MlsMigrationConfigB_Bare_Identity_LTQyMDAxMTkz"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_MsignCfBIdy_LTE1NjAxNjU2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/enforceFileDownloadLocation":{"get":{"summary":"Get config for enforceFileDownloadLocation","description":" [internal route ID: (\"get\", EnforceFileDownloadLocationConfigB)]\n\n

Custom feature: only supported on some dedicated on-prem systems.

","operationId":"get_EnforceFileDownloadLocationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for enforceFileDownloadLocation","description":" [internal route ID: (\"put\", EnforceFileDownloadLocationConfigB)]\n\n

Custom feature: only supported on some dedicated on-prem systems.

","operationId":"put_EnforceFileDownloadLocationConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Featur_EnfocilDwdLCgBIy_MjcxMzc1NDA5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/limitedEventFanout":{"get":{"summary":"Get config for limitedEventFanout","description":" [internal route ID: (\"get\", LimitedEventFanoutConfig)]\n\n","operationId":"get_LimitedEventFanoutConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_LimitedEventFanoutConfig_MTg3ODM0NzU0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/feature-configs":{"get":{"summary":"Gets feature configs for a user","description":" [internal route ID: \"get-all-feature-configs-for-user\"]\n\nGets feature configs for a user. If the user is a member of a team and has the required permissions, this will return the team's feature configs.If the user is not a member of a team, this will return the personal feature configs (the server defaults).\nOAuth scope: `read:feature_configs`","operationId":"get-all-feature-configs-for-user","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NP_LockableFtur_ghdCnfiSOVsyAvIRqExmDpB_MGwUT2Q_LTM1Mzc0ODgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)"}}}},"/teams/{tid}/features":{"get":{"summary":"Gets feature configs for a team","description":" [internal route ID: \"get-all-feature-configs-for-team\"]\n\nGets feature configs for a team. User must be a member of the team and have permission to view team features.","operationId":"get-all-feature-configs-for-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NP_LockableFtur_ghdCnfiSOVsyAvIRqExmDpB_MGwUT2Q_LTM1Mzc0ODgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/domainRegistration":{"get":{"summary":"Get config for domainRegistration","description":" [internal route ID: (\"get\", DomainRegistrationConfig)]\n\n","operationId":"get_DomainRegistrationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_DomainRegistrationConfig_NzU0NjczNTE0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/channels":{"get":{"summary":"Get config for channels","description":" [internal route ID: (\"get\", ChannelsConfigB)]\n\n","operationId":"get_ChannelsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for channels","description":" [internal route ID: (\"put\", ChannelsConfigB)]\n\n","operationId":"put_ChannelsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_ChannelsConfigB_Bare_Identity_LTU4NTE4MTgx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/preventAdminlessGroups":{"get":{"summary":"Get config for preventAdminlessGroups","description":" [internal route ID: (\"get\", PreventAdminlessGroupsConfigB)]\n\n","operationId":"get_PreventAdminlessGroupsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for preventAdminlessGroups","description":" [internal route ID: \"put-PreventAdminlessGroupsConfig@v17\"]\n\n

For API version 17, use duration strings for the timeout fields. The request body must have the following shape:

{\n  "config": {\n    "deletionTimeoutDuration": "7d",\n    "promotionStrategy": "alphabetical",\n    "reminderTimeoutDurations": [\n      "2d",\n      "4d",\n      "6d"\n    ]\n  },\n  "status": "enabled"\n}

Older API versions use the legacy numeric fields deletionTimeout and reminderTimeouts.

","operationId":"put-PreventAdminlessGroupsConfig@v17","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Featur_Vsiond_17PvAmlGpCfgBIy_LTIzMjQ4MDk1V17"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/cells":{"get":{"summary":"Get config for cells","description":" [internal route ID: (\"get\", CellsConfigB)]\n\n","operationId":"get_CellsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for cells","description":" [internal route ID: (\"put\", CellsConfigB)]\n\n","operationId":"put_CellsConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_CellsConfigB_Bare_Identity_NzU2NjgxNzEw"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/allowedGlobalOperations":{"get":{"summary":"Get config for allowedGlobalOperations","description":" [internal route ID: (\"get\", AllowedGlobalOperationsConfig)]\n\n","operationId":"get_AllowedGlobalOperationsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AllowedGlobalOperationsConfig_NjQ1MjA5MDYw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/assetAuditLog":{"get":{"summary":"Get config for assetAuditLog","description":" [internal route ID: (\"get\", AssetAuditLogConfig)]\n\n","operationId":"get_AssetAuditLogConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AssetAuditLogConfig_NDQ3MzcyMzk2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/consumableNotifications":{"get":{"summary":"Get config for consumableNotifications","description":" [internal route ID: (\"get\", ConsumableNotificationsConfig)]\n\n","operationId":"get_ConsumableNotificationsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ConsumableNotificationsConfig_MjUxMjczMjM0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/chatBubbles":{"get":{"summary":"Get config for chatBubbles","description":" [internal route ID: (\"get\", ChatBubblesConfig)]\n\n","operationId":"get_ChatBubblesConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_ChatBubblesConfig_NDgwMTQzNjI2"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/apps":{"get":{"summary":"Get config for apps","description":" [internal route ID: (\"get\", AppsConfig)]\n\n","operationId":"get_AppsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_AppsConfig_MzQyNTMxNTk5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/simplifiedUserConnectionRequestQRCode":{"get":{"summary":"Get config for simplifiedUserConnectionRequestQRCode","description":" [internal route ID: (\"get\", SimplifiedUserConnectionRequestQRCodeConfig)]\n\n","operationId":"get_SimplifiedUserConnectionRequestQRCodeConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_SimpfdUsCnRqQg_Njk5NjU4OTgy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/stealthUsers":{"get":{"summary":"Get config for stealthUsers","description":" [internal route ID: (\"get\", StealthUsersConfig)]\n\n","operationId":"get_StealthUsersConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_StealthUsersConfig_LTE1MTk2NzIz"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/cellsInternal":{"get":{"summary":"Get config for cellsInternal","description":" [internal route ID: (\"get\", CellsInternalConfigB)]\n\n","operationId":"get_CellsInternalConfigB","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_CsInfigBdy_LTQ3MTU3Mjg0"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/features/meetings":{"get":{"summary":"Get config for meetings","description":" [internal route ID: (\"get\", MeetingsConfig)]\n\n","operationId":"get_MeetingsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}},"put":{"summary":"Put config for meetings","description":" [internal route ID: (\"put\", MeetingsConfig)]\n\n","operationId":"put_MeetingsConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Feature_MeetingsConfig_NDc2MzM0MDE1"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}}}},"/mls/messages":{"post":{"summary":"Post an MLS message","description":" [internal route ID: \"mls-message\"]\n\n\n\n**Note**: this endpoint can execute proposals, and therefore return all possible errors associated with adding or removing members to a conversation, in addition to the ones listed below. See the documentation of [POST /conversations/{cnv}/members/v2](#/default/post_conversations__cnv__members_v2) and [POST /conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}](#/default/delete_conversations__cnv_domain___cnv__members__usr_domain___usr_) for more details on the possible error responses of each type of proposal.","operationId":"mls-message","requestBody":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/MLSMessage"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}}},"description":"Message sent"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-invalid-leaf-node-signature","message":"Invalid leaf node signature"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-invalid-leaf-node-signature","mls-self-removal-not-allowed","mls-protocol-error","mls-not-enabled","mls-invalid-leaf-node-index","mls-group-conversation-mismatch","mls-commit-missing-references","mls-client-sender-user-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid leaf node signature (label: `mls-invalid-leaf-node-signature`)\n\nSubmitted group info is inconsistent with the backend group state (label `inconsistent-group-state`)\n\nSelf removal from group is not allowed (label: `mls-self-removal-not-allowed`)\n\nMLS protocol error (label: `mls-protocol-error`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nA referenced leaf node index points to a blank or non-existing node (label: `mls-invalid-leaf-node-index`)\n\nConversation ID resolved from Group ID does not match submitted Conversation ID (label: `mls-group-conversation-mismatch`)\n\nThe commit is not referencing all pending proposals (label: `mls-commit-missing-references`)\n\nUser ID resolved from Client ID does not match message's sender user ID (label: `mls-client-sender-user-mismatch`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-subconv-join-parent-missing","message":"MLS client cannot join the subconversation because it is not member of the parent conversation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-subconv-join-parent-missing","missing-legalhold-consent","legalhold-not-enabled","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS client cannot join the subconversation because it is not member of the parent conversation (label: `mls-subconv-join-parent-missing`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-proposal-not-found","message":"A proposal referenced in a commit message could not be found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-proposal-not-found","no-conversation","no-conversation-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"A proposal referenced in a commit message could not be found (label: `mls-proposal-not-found`)\n\nConversation not found (label: `no-conversation`)\n\nConversation member not found (label: `no-conversation-member`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"missing_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["missing_users"],"type":"object"}}},"description":"Group is out of sync\n\nAdding members to the conversation is not possible because the backends involved do not form a fully connected graph\n\nThe conversation epoch in a message is too old (label: `mls-stale-message`)\n\nA proposal of type Add or Remove does not apply to the full list of clients for a user (label: `mls-client-mismatch`)"},"422":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":422,"label":"mls-unsupported-proposal","message":"Unsupported proposal type"},"properties":{"code":{"enum":[422],"type":"integer"},"label":{"enum":["mls-unsupported-proposal","mls-unsupported-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unsupported proposal type (label: `mls-unsupported-proposal`)\n\nAttempted to send a message with an unsupported combination of content type and wire format (label: `mls-unsupported-message`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}}},"/mls/commit-bundles":{"post":{"summary":"Post a MLS CommitBundle","description":" [internal route ID: \"mls-commit-bundle\"]\n\n\n\n**Note**: this endpoint can execute proposals, and therefore return all possible errors associated with adding or removing members to a conversation, in addition to the ones listed below. See the documentation of [POST /conversations/{cnv}/members/v2](#/default/post_conversations__cnv__members_v2) and [POST /conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}](#/default/delete_conversations__cnv_domain___cnv__members__usr_domain___usr_) for more details on the possible error responses of each type of proposal.","operationId":"mls-commit-bundle","requestBody":{"content":{"message/mls":{"schema":{"$ref":"#/components/schemas/CommitBundle"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSMessageSendingStatus_NjA1NDA0MTE4"}}},"description":"Commit accepted and forwarded"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-invalid-leaf-node-signature","message":"Invalid leaf node signature"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-invalid-leaf-node-signature","mls-group-id-not-supported","mls-welcome-mismatch","mls-self-removal-not-allowed","mls-protocol-error","mls-not-enabled","mls-invalid-leaf-node-index","mls-group-conversation-mismatch","mls-commit-missing-references","mls-client-sender-user-mismatch"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid leaf node signature (label: `mls-invalid-leaf-node-signature`)\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)\n\nSubmitted group info is inconsistent with the backend group state (label `inconsistent-group-state`)\n\nThe list of targets of a welcome message does not match the list of new clients in a group (label: `mls-welcome-mismatch`)\n\nSelf removal from group is not allowed (label: `mls-self-removal-not-allowed`)\n\nMLS protocol error (label: `mls-protocol-error`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nA referenced leaf node index points to a blank or non-existing node (label: `mls-invalid-leaf-node-index`)\n\nConversation ID resolved from Group ID does not match submitted Conversation ID (label: `mls-group-conversation-mismatch`)\n\nThe commit is not referencing all pending proposals (label: `mls-commit-missing-references`)\n\nUser ID resolved from Client ID does not match message's sender user ID (label: `mls-client-sender-user-mismatch`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"mls-identity-mismatch","message":"Leaf node signature key does not match the client's key"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["mls-identity-mismatch","mls-subconv-join-parent-missing","missing-legalhold-consent","legalhold-not-enabled","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Leaf node signature key does not match the client's key (label: `mls-identity-mismatch`)\n\nMLS client cannot join the subconversation because it is not member of the parent conversation (label: `mls-subconv-join-parent-missing`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"mls-proposal-not-found","message":"A proposal referenced in a commit message could not be found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["mls-proposal-not-found","no-conversation","no-conversation-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"A proposal referenced in a commit message could not be found (label: `mls-proposal-not-found`)\n\nConversation not found (label: `no-conversation`)\n\nConversation member not found (label: `no-conversation-member`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"missing_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["missing_users"],"type":"object"}}},"description":"Group is out of sync\n\nAdding members to the conversation is not possible because the backends involved do not form a fully connected graph\n\nA user who is under legal-hold may not participate in MLS conversations (label: `mls-legal-hold-not-allowed`)\n\nThe conversation epoch in a message is too old (label: `mls-stale-message`)\n\nA proposal of type Add or Remove does not apply to the full list of clients for a user (label: `mls-client-mismatch`)"},"422":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":422,"label":"mls-unsupported-proposal","message":"Unsupported proposal type"},"properties":{"code":{"enum":[422],"type":"integer"},"label":{"enum":["mls-unsupported-proposal","mls-unsupported-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unsupported proposal type (label: `mls-unsupported-proposal`)\n\nAttempted to send a message with an unsupported combination of content type and wire format (label: `mls-unsupported-message`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}}},"/mls/public-keys":{"get":{"summary":"Get public keys used by the backend to sign external proposals","description":" [internal route ID: \"mls-public-keys\"]\n\nThe format of the returned key is determined by the `format` query parameter:\n - raw (default): base64-encoded raw public keys\n - jwk: keys are nested objects in JWK format.","operationId":"mls-public-keys","parameters":[{"in":"query","name":"format","required":false,"schema":{"enum":["raw","jwk"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx"}}},"description":"Public keys"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-not-enabled","message":"MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `format`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)"}}}},"/mls/reset-conversation":{"post":{"summary":"Reset an MLS conversation to epoch 0","description":" [internal route ID: \"mls-reset-conversation\"]\n\n","operationId":"mls-reset-conversation","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MLSReset_NzgwODA3ODc4"}}},"required":true},"responses":{"200":{"description":"Conversation reset"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"mls-protocol-error","message":"MLS protocol error"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["mls-protocol-error","mls-group-id-not-supported","mls-federated-reset-not-supported","mls-not-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"MLS protocol error (label: `mls-protocol-error`)\n\nThe group ID version of the conversation is not supported by one of the federated backends (label: `mls-group-id-not-supported`)\n\nReset is not supported by the owning backend of the conversation (label: `mls-federated-reset-not-supported`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`) or `body`"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"action-denied","message":"Insufficient authorization (missing leave_conversation)"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["action-denied","invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-conversation","message":"Conversation not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-conversation"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Conversation not found (label: `no-conversation`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-stale-message","message":"The conversation epoch in a message is too old"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-stale-message"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"The conversation epoch in a message is too old (label: `mls-stale-message`)"}}}},"/meetings":{"post":{"summary":"Create a new meeting","description":" [internal route ID: \"create-meeting\"]\n\n","operationId":"create-meeting","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewMeeting_LTI1NTMzOTU5"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeetingWithConversation_LTMyNzA4NzU0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MeetingWithConversation_LTMyNzA4NzU0"}}},"description":"Meeting created"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid meeting times, empty update, or meetings feature disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid meeting times, empty update, or meetings feature disabled (label: `invalid-op`)"},"533":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"unreachable_backends":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["unreachable_backends"],"type":"object"}}},"description":"Some domains are unreachable"}}}},"/meetings/{domain}/{id}":{"get":{"summary":"Get a single meeting by ID","description":" [internal route ID: \"get-meeting\"]\n\n","operationId":"get-meeting","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}}},"put":{"summary":"Update an existing meeting","description":" [internal route ID: \"update-meeting\"]\n\n","operationId":"update-meeting","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateMeeting_NTExNzYxMTcz"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeetingWithConversation_LTMyNzA4NzU0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MeetingWithConversation_LTMyNzA4NzU0"}}},"description":"Meeting updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid meeting times, empty update, or meetings feature disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid meeting times, empty update, or meetings feature disabled (label: `invalid-op`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}}},"delete":{"summary":"Delete a meeting","description":" [internal route ID: \"delete-meeting\"]\n\n","operationId":"delete-meeting","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"Meeting deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid meeting times, empty update, or meetings feature disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid meeting times, empty update, or meetings feature disabled (label: `invalid-op`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}}}},"/meetings/list":{"get":{"summary":"List all meetings for the authenticated user","description":" [internal route ID: \"list-meetings\"]\n\n","operationId":"list-meetings","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/Meeting_ODU0OTMzMTgw"},"type":"array"}}},"description":""}}}},"/meetings/{domain}/{id}/invitations":{"put":{"summary":"Replace the invited emails","description":" [internal route ID: \"replace-meeting-invitation\"]\n\n","operationId":"replace-meeting-invitation","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MeetingEmailsInvitation_NzgyNzUzMzcz"}}},"required":true},"responses":{"200":{"description":"Invitations replaced"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid meeting times, empty update, or meetings feature disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid meeting times, empty update, or meetings feature disabled (label: `invalid-op`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}}},"post":{"summary":"Add an email to the invited emails","description":" [internal route ID: \"add-meeting-invitation\"]\n\n","operationId":"add-meeting-invitation","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MeetingEmailsInvitation_NzgyNzUzMzcz"}}},"required":true},"responses":{"200":{"description":"Invitation added"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid meeting times, empty update, or meetings feature disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid meeting times, empty update, or meetings feature disabled (label: `invalid-op`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}}}},"/meetings/{domain}/{id}/invitations/delete":{"post":{"summary":"Remove emails from the invited emails","description":" [internal route ID: \"remove-meeting-invitation\"]\n\n","operationId":"remove-meeting-invitation","parameters":[{"in":"path","name":"domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/MeetingEmailsInvitation_NzgyNzUzMzcz"}}},"required":true},"responses":{"200":{"description":"Invitations removed"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid meeting times, empty update, or meetings feature disabled"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid meeting times, empty update, or meetings feature disabled (label: `invalid-op`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"meeting-not-found","message":"Meeting not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["meeting-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` or `id` or Meeting not found (label: `meeting-not-found`)"}}}},"/custom-backend/by-domain/{domain}":{"get":{"summary":"Shows information about custom backends related to a given email domain","description":" [internal route ID: \"get-custom-backend-by-domain\"]\n\n","operationId":"get-custom-backend-by-domain","parameters":[{"description":"URL-encoded email domain","in":"path","name":"domain","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CustomBackend_LTQxODI0MjQ0"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"custom-backend-not-found","message":"Custom backend not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["custom-backend-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`domain` not found\n\nCustom backend not found (label: `custom-backend-not-found`)"}}}},"/teams/{tid}/legalhold/settings":{"get":{"summary":"Get legal hold service settings","description":" [internal route ID: \"get-legal-hold-settings\"]\n\n","operationId":"get-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}}},"post":{"summary":"Create legal hold service settings","description":" [internal route ID: \"create-legal-hold-settings\"]\n\n","operationId":"create-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewLegalHoldService_Mzg0ODQ5NDU1"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}}},"description":"Legal hold service settings created"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-status-bad","message":"legal hold service: invalid response"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-status-bad","legalhold-invalid-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)\n\nlegal hold service pubkey is invalid (label: `legalhold-invalid-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}}},"delete":{"summary":"Delete legal hold service settings","description":" [internal route ID: \"delete-legal-hold-settings\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to members with a legalhold client (via brig)\n- UserLegalHoldDisabled event to contacts of members with a legalhold client (via brig)","operationId":"delete-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RemoveLegalHoldSettingsRequest_NzMzMzMyMDAz"}}},"required":true},"responses":{"204":{"description":"Legal hold service settings deleted"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-disable-unimplemented","message":"legal hold cannot be disabled for whitelisted teams"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-disable-unimplemented","legalhold-not-enabled","invalid-op","action-denied","no-team-member","operation-denied","code-authentication-required","code-authentication-failed","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}}}},"/teams/{tid}/legalhold/{uid}":{"get":{"summary":"Get legal hold status","description":" [internal route ID: \"get-legal-hold\"]\n\n","operationId":"get-legal-hold","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserLegalHoldStatusResponse_LTQ1MzUxMTE3"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"}}},"post":{"summary":"Request legal hold device","description":" [internal route ID: \"request-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- LegalHoldClientRequested event to contacts of the user the device is requested for, if they didn't already have a legalhold client (via brig)","operationId":"request-legal-hold-device","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"201":{"description":"Request device successful"},"204":{"description":"Request device already pending"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered","legalhold-status-bad"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service has not been registered for this team (label: `legalhold-not-registered`)\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","operation-denied","no-team-member","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-legal-hold-not-allowed","message":"A user who is under legal-hold may not participate in MLS conversations"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-legal-hold-not-allowed","legalhold-no-consent","legalhold-already-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"A user who is under legal-hold may not participate in MLS conversations (label: `mls-legal-hold-not-allowed`)\n\nuser has not given consent to using legal hold (label: `legalhold-no-consent`)\n\nlegal hold is already enabled for this user (label: `legalhold-already-enabled`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-illegal-op","message":"internal server error: inconsistent change of user's legalhold state"},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-illegal-op","legalhold-internal"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"internal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)\n\nlegal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)"}}},"delete":{"summary":"Disable legal hold for user","description":" [internal route ID: \"disable-legal-hold-for-user\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to the user owning the client (via brig)\n- UserLegalHoldDisabled event to contacts of the user owning the client (via brig)","operationId":"disable-legal-hold-for-user","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DisableLegalHoldForUserRequest_LTYyMDYxOTEy"}}},"required":true},"responses":{"200":{"description":"Disable legal hold successful"},"204":{"description":"Legal hold was not enabled"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","action-denied","code-authentication-required","code-authentication-failed","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}}}},"/teams/{tid}/legalhold/consent":{"post":{"summary":"Consent to legal hold","description":" [internal route ID: \"consent-to-legal-hold\"]\n\n","operationId":"consent-to-legal-hold","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"201":{"description":"Grant consent successful"},"204":{"description":"Consent already granted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam member not found (label: `no-team-member`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}}}},"/teams/{tid}/legalhold/{uid}/approve":{"put":{"summary":"Approve legal hold device","description":" [internal route ID: \"approve-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientAdded event to the user owning the client (via brig)\n- UserLegalHoldEnabled event to contacts of the user owning the client (via brig)\n- ClientRemoved event to the user, if removing old client due to max number (via brig)","operationId":"approve-legal-hold-device","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ApproveLegalHoldForUserRequest_NjEyNzYyMTIx"}}},"required":true},"responses":{"200":{"description":"Legal hold approved"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","no-team-member","action-denied","access-denied","code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"legalhold-no-device-allocated","message":"no legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["legalhold-no-device-allocated"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nno legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow. (label: `legalhold-no-device-allocated`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"legalhold-already-enabled","message":"legal hold is already enabled for this user"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["legalhold-already-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is already enabled for this user (label: `legalhold-already-enabled`)"},"412":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":412,"label":"legalhold-not-pending","message":"legal hold cannot be approved without being in a pending state"},"properties":{"code":{"enum":[412],"type":"integer"},"label":{"enum":["legalhold-not-pending"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold cannot be approved without being in a pending state (label: `legalhold-not-pending`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}}}},"/teams/{tid}/members":{"get":{"summary":"Get team members","description":" [internal route ID: \"get-team-members\"]\n\n","operationId":"get-team-members","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Maximum results to be returned","in":"query","name":"maxResults","required":false,"schema":{"format":"int32","maximum":2000,"minimum":1,"type":"integer"}},{"description":"Optional, when not specified, the first page will be returned.Every returned page contains a `pagingState`, this should be supplied to retrieve the next page.","in":"query","name":"pagingState","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMembersPage_NzYwNDIxODgx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"}}},"put":{"summary":"Update an existing team member","description":" [internal route ID: \"update-team-member\"]\n\n","operationId":"update-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewTeamMember_Required_LTg2NjU5OTI2"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","too-many-team-admins","invalid-permissions","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nMaximum number of admins per team reached (label: `too-many-team-admins`)\n\nThe specified permissions are invalid (label: `invalid-permissions`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member","no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam member not found (label: `no-team-member`)\n\nTeam not found (label: `no-team`)"}}}},"/teams/{tid}/members/{uid}":{"get":{"summary":"Get single team member","description":" [internal route ID: \"get-team-member\"]\n\n","operationId":"get-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMember_Optional_NTU0MDcyNzI1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"}}},"delete":{"summary":"Remove an existing team member","description":" [internal route ID: \"delete-team-member\"]\n\n","operationId":"delete-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMemberDeleteData_LTg2OTEyOTI4"}}},"required":true},"responses":{"200":{"description":""},"202":{"description":"Team member scheduled for deletion"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","access-denied","code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam not found (label: `no-team`)\n\nTeam member not found (label: `no-team-member`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"}}}},"/teams/{tid}/get-members-by-ids-using-post":{"post":{"summary":"Get team members by user id list","description":" [internal route ID: \"get-team-members-by-ids\"]\n\nThe `has_more` field in the response body is always `false`.","operationId":"get-team-members-by-ids","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Maximum results to be returned","in":"query","name":"maxResults","required":false,"schema":{"format":"int32","maximum":2000,"minimum":1,"type":"integer"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserIdList_MzA1MTI1Njgx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMemberList_Optional_LTM1ODE2MzM0"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"too-many-uids","message":"Can only process 2000 user ids per request."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["too-many-uids"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `maxResults`\n\nCan only process 2000 user ids per request. (label: `too-many-uids`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"}}}},"/teams/{tid}/members/csv":{"get":{"summary":"Get all members of the team as a CSV file","description":" [internal route ID: \"get-team-members-csv\"]\n\nThe endpoint returns data in chunked transfer encoding. Internal server errors might result in a failed transfer instead of a 500 response.","operationId":"get-team-members-csv","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/csv":{}},"description":"CSV of team members"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"You do not have permission to access this resource"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"You do not have permission to access this resource (label: `access-denied`)"}}}},"/teams/{tid}/collaborators":{"get":{"summary":"Get all collaborators of the team.","description":" [internal route ID: \"get-team-collaborators\"]\n\n","operationId":"get-team-collaborators","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/TeamCollaborator_LTI3MzM1MTYz"},"type":"array"}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/TeamCollaborator_LTI3MzM1MTYz"},"type":"array"}}},"description":"Return collaborators"}}},"post":{"summary":"Add a collaborator to the team.","description":" [internal route ID: \"add-team-collaborator\"]\n\n","operationId":"add-team-collaborator","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewTeamCollaborator_LTIxNjEzMTYw"}}},"required":true},"responses":{"200":{"description":""}}}},"/teams/{tid}/collaborators/{uid}":{"put":{"summary":"Update a collaborator permissions from the team.","description":" [internal route ID: \"update-team-collaborator\"]\n\n","operationId":"update-team-collaborator","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/CollaboratorPermission_NDg5NTg2ODgy"},"type":"array","uniqueItems":true}}},"required":true},"responses":{"200":{"description":""}}},"delete":{"summary":"Remove a collaborator from the team.","description":" [internal route ID: \"remove-team-collaborator\"]\n\n","operationId":"remove-team-collaborator","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"}}}},"/teams/notifications":{"get":{"summary":"Read recently added team members from team queue","description":" [internal route ID: \"get-team-notifications\"]\n\n

This is a work-around for scalability issues with gundeck user event fan-out. It does not track all team-wide events, but only `member-join`.

Note that `/teams/notifications` behaves differently from `/notifications`:

  • If there is a gap between the notification id requested with `since` and the available data, team queues respond with 200 and the data that could be found. They do NOT respond with status 404, but valid data in the body.\n
  • The notification with the id given via `since` is included in the response if it exists. You should remove this and only use it to decide whether there was a gap between your last request and this one.\n
  • If the notification id does *not* exist, you get the more recent events from the queue (instead of all of them). This can be done because a notification id is a UUIDv1, which is essentially a time stamp.\n
  • There is no corresponding `/last` end-point to get only the most recent event. That end-point was only useful to avoid having to pull the entire queue. In team queues, if you have never requested the queue before and have no prior notification id, just pull with timestamp 'now'.

See also: GET /notifications

","operationId":"get-team-notifications","parameters":[{"description":"Notification id to start with in the response (UUIDv1)","in":"query","name":"since","required":false,"schema":{"format":"uuid","type":"string"}},{"description":"Maximum number of events to return (1..10000; default: 1000)","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":10000,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotificationList_MTU0ODEyNTQ2"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-notification-id","message":"Could not parse notification id (must be UUIDv1)."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-notification-id"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `size` or `since`\n\nCould not parse notification id (must be UUIDv1). (label: `invalid-notification-id`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Team not found (label: `no-team`)"}}}},"/sso/metadata":{"get":{"description":" [internal route ID: \"sso-metadata\"]\n\nDEPRECATED! use /sso/metadata/:tid instead! Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams","operationId":"sso-metadata","responses":{"200":{"content":{"application/xml":{"schema":{"type":"string"}}},"description":""}},"deprecated":true}},"/sso/metadata/{team}":{"get":{"description":" [internal route ID: \"sso-team-metadata\"]\n\n","operationId":"sso-team-metadata","parameters":[{"in":"path","name":"team","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/xml":{"schema":{"type":"string"}}},"description":""}}}},"/sso/initiate-login/{idp}":{"get":{"description":" [internal route ID: \"auth-req\"]\n\n","operationId":"auth-req","parameters":[{"in":"query","name":"success_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"error_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"label","required":false,"schema":{"type":"string"}},{"in":"path","name":"idp","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/html":{"schema":{"$ref":"#/components/schemas/FormRedirect"}}},"description":""}}},"head":{"description":" [internal route ID: \"auth-req-precheck\"]\n\n","operationId":"auth-req-precheck","parameters":[{"in":"query","name":"success_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"error_redirect","required":false,"schema":{"type":"string"}},{"in":"query","name":"label","required":false,"schema":{"type":"string"}},{"in":"path","name":"idp","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/plain;charset=utf-8":{}},"description":""}}}},"/sso/finalize-login":{"post":{"description":" [internal route ID: \"auth-resp-legacy\"]\n\nDEPRECATED! use /sso/metadata/:tid instead! Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams","operationId":"auth-resp-legacy","responses":{"200":{"content":{"text/plain;charset=utf-8":{"schema":{"type":"string"}}},"description":""}},"deprecated":true}},"/sso/finalize-login/{team}":{"post":{"description":" [internal route ID: \"auth-resp\"]\n\n","operationId":"auth-resp","parameters":[{"in":"path","name":"team","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/plain;charset=utf-8":{"schema":{"type":"string"}}},"description":""}}}},"/sso/settings":{"get":{"description":" [internal route ID: \"sso-settings\"]\n\n","operationId":"sso-settings","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SsoSettings"}}},"description":""}}}},"/sso/get-by-email":{"post":{"description":" [internal route ID: \"sso-get-by-email\"]\n\n","operationId":"sso-get-by-email","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetByEmailReq_LTY4MzE3Njgy"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}}},"description":"SSO code found"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/GetByEmailResp_LTMxNTY3MjA0"}}},"description":"SSO code not found or feature disabled"}}}},"/identity-providers/{id}":{"get":{"description":" [internal route ID: \"idp-get\"]\n\n","operationId":"idp-get","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"}}},"description":""}}},"put":{"description":" [internal route ID: \"idp-update\"]\n\n","operationId":"idp-update","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"handle","required":false,"schema":{"maxLength":32,"minLength":1,"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}},"application/xml":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"}}},"description":""}}},"delete":{"description":" [internal route ID: \"idp-delete\"]\n\n","operationId":"idp-delete","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"purge","required":false,"schema":{"type":"boolean"}}],"responses":{"204":{"description":""}}}},"/identity-providers/{id}/raw":{"get":{"description":" [internal route ID: \"idp-get-raw\"]\n\n","operationId":"idp-get-raw","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/xml":{"schema":{"type":"string"}}},"description":""}}}},"/identity-providers":{"get":{"description":" [internal route ID: \"idp-get-all\"]\n\n","operationId":"idp-get-all","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPList"}}},"description":""}}},"post":{"description":" [internal route ID: \"idp-create\"]\n\nCreate a new identity provider.\n\nThe `api_version` parameter controls the uniqueness constraint for IdP issuers:\n- `v1`: IdP issuers must be globally unique across the entire backend (all teams)\n- `v2` (default): IdP issuers must be unique per team (can be reused across different teams)\n\nThese constraints apply to both, multi-ingress and standard backends.","operationId":"idp-create","parameters":[{"in":"query","name":"replaces","required":false,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"api_version","required":false,"schema":{"default":"v2","enum":["v1","v2"],"type":"string"}},{"in":"query","name":"handle","required":false,"schema":{"maxLength":32,"minLength":1,"type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}},"application/xml":{"schema":{"$ref":"#/components/schemas/IdPMetadataInfo"}}},"required":true},"responses":{"201":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"}}},"description":""}}}},"/scim/auth-tokens":{"get":{"description":" [internal route ID: \"auth-tokens-list\"]\n\n","operationId":"auth-tokens-list","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ScimTokenList_NjQwNTYxOTAw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}},"post":{"description":" [internal route ID: \"auth-tokens-create\"]\n\n","operationId":"auth-tokens-create","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateScimToken_OTY0NjYxMDQ2"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateScimTokenResponse_LTIzOTU2NDU4"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}},"delete":{"description":" [internal route ID: \"auth-tokens-delete\"]\n\n","operationId":"auth-tokens-delete","parameters":[{"in":"query","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}}},"/scim/auth-tokens/{id}":{"put":{"description":" [internal route ID: \"auth-tokens-put-name\"]\n\n","operationId":"auth-tokens-put-name","parameters":[{"in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ScimTokenName_LTgzOTM2OTI4"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"code-authentication-required","message":"Code authentication is required"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)"}}}},"/bot/assets":{"post":{"summary":"Upload an asset","description":" [internal route ID: (\"assets-upload-v3\", bot)]\n\n

Construct the request as multipart/mixed; set header Content-Type: multipart/mixed; boundary=<boundary>.

Use exactly two parts in this order:

  1. application/json metadata (AssetSettings)
  2. application/octet-stream asset bytes

Each part must include Content-Type and Content-Length; the second part may include Content-MD5. Use CRLF between headers and bodies.

When asset audit logging is enabled, the JSON metadata must include:

  • convId: object { id: UUID, domain: String } (qualified conversation ID)
  • filename: String
  • filetype: String MIME type (e.g. image/png, application/pdf)

Optional metadata: public (Bool, default false), retention (one of eternal, persistent, volatile, eternal-infrequent_access, expiring).

For profile pictures or team icons without a conversation, set convId.id to 00000000-0000-0000-0000-000000000000 and convId.domain to the tenant’s domain; use any reasonable filename.

Note: the server treats the asset bytes as application/octet-stream; filetype is used for auditing only.

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

--frontier
Content-Type: application/json
Content-Length: 191

{\"public\":false,\"retention\":\"volatile\",\"convId\":{\"id\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"domain\":\"example.com\"},\"filename\":\"report.pdf\",\"filetype\":\"application/pdf\"}
--frontier
Content-Type: application/octet-stream
Content-Length: 11

Hello Audit
--frontier--
","operationId":"assets-upload-v3_bot","requestBody":{"content":{"multipart/mixed":{"schema":{"$ref":"#/components/schemas/AssetSource"}}},"description":"A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server."},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}}},"description":"Asset posted","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"incomplete-body","message":"HTTP content-length header does not match body size"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["incomplete-body","invalid-length"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nHTTP content-length header does not match body size (label: `incomplete-body`)\n\nInvalid content length (label: `invalid-length`)"},"413":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"client-error","message":"Asset too large"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["client-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Asset too large (label: `client-error`)"}}}},"/bot/assets/{key}":{"get":{"summary":"Download an asset","description":" [internal route ID: (\"assets-download-v3\", bot)]\n\n","operationId":"assets-download-v3_bot","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}},{"in":"header","name":"Asset-Token","required":false,"schema":{"type":"string"}},{"in":"query","name":"asset_token","required":false,"schema":{"type":"string"}}],"responses":{"302":{"description":"Asset found","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` or Asset not found (label: `not-found`)"}}},"delete":{"summary":"Delete an asset","description":" [internal route ID: (\"assets-delete-v3\", bot)]\n\n","operationId":"assets-delete-v3_bot","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` not found\n\nAsset not found (label: `not-found`)"}}}},"/provider/assets":{"post":{"summary":"Upload an asset","description":" [internal route ID: (\"assets-upload-v3\", provider)]\n\n

Construct the request as multipart/mixed; set header Content-Type: multipart/mixed; boundary=<boundary>.

Use exactly two parts in this order:

  1. application/json metadata (AssetSettings)
  2. application/octet-stream asset bytes

Each part must include Content-Type and Content-Length; the second part may include Content-MD5. Use CRLF between headers and bodies.

When asset audit logging is enabled, the JSON metadata must include:

  • convId: object { id: UUID, domain: String } (qualified conversation ID)
  • filename: String
  • filetype: String MIME type (e.g. image/png, application/pdf)

Optional metadata: public (Bool, default false), retention (one of eternal, persistent, volatile, eternal-infrequent_access, expiring).

For profile pictures or team icons without a conversation, set convId.id to 00000000-0000-0000-0000-000000000000 and convId.domain to the tenant’s domain; use any reasonable filename.

Note: the server treats the asset bytes as application/octet-stream; filetype is used for auditing only.

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

--frontier
Content-Type: application/json
Content-Length: 191

{\"public\":false,\"retention\":\"volatile\",\"convId\":{\"id\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"domain\":\"example.com\"},\"filename\":\"report.pdf\",\"filetype\":\"application/pdf\"}
--frontier
Content-Type: application/octet-stream
Content-Length: 11

Hello Audit
--frontier--
","operationId":"assets-upload-v3_provider","requestBody":{"content":{"multipart/mixed":{"schema":{"$ref":"#/components/schemas/AssetSource"}}},"description":"A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server."},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}}},"description":"Asset posted","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"incomplete-body","message":"HTTP content-length header does not match body size"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["incomplete-body","invalid-length"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nHTTP content-length header does not match body size (label: `incomplete-body`)\n\nInvalid content length (label: `invalid-length`)"},"413":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"client-error","message":"Asset too large"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["client-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Asset too large (label: `client-error`)"}}}},"/provider/assets/{key}":{"get":{"summary":"Download an asset","description":" [internal route ID: (\"assets-download-v3\", provider)]\n\n","operationId":"assets-download-v3_provider","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}},{"in":"header","name":"Asset-Token","required":false,"schema":{"type":"string"}},{"in":"query","name":"asset_token","required":false,"schema":{"type":"string"}}],"responses":{"302":{"description":"Asset found","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` or Asset not found (label: `not-found`)"}}},"delete":{"summary":"Delete an asset","description":" [internal route ID: (\"assets-delete-v3\", provider)]\n\n","operationId":"assets-delete-v3_provider","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` not found\n\nAsset not found (label: `not-found`)"}}}},"/assets/{key}/token":{"post":{"summary":"Renew an asset token","description":" [internal route ID: \"tokens-renew\"]\n\n","operationId":"tokens-renew","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewAssetToken_NTAwMDQwODYy"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key` not found\n\nAsset not found (label: `not-found`)"}}},"delete":{"summary":"Delete an asset token","description":" [internal route ID: \"tokens-delete\"]\n\n**Note**: deleting the token makes the asset public.","operationId":"tokens-delete","parameters":[{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset token deleted"}}}},"/assets":{"post":{"summary":"Upload an asset","description":" [internal route ID: \"assets-upload\"]\n\n

Construct the request as multipart/mixed; set header Content-Type: multipart/mixed; boundary=<boundary>.

Use exactly two parts in this order:

  1. application/json metadata (AssetSettings)
  2. application/octet-stream asset bytes

Each part must include Content-Type and Content-Length; the second part may include Content-MD5. Use CRLF between headers and bodies.

When asset audit logging is enabled, the JSON metadata must include:

  • convId: object { id: UUID, domain: String } (qualified conversation ID)
  • filename: String
  • filetype: String MIME type (e.g. image/png, application/pdf)

Optional metadata: public (Bool, default false), retention (one of eternal, persistent, volatile, eternal-infrequent_access, expiring).

For profile pictures or team icons without a conversation, set convId.id to 00000000-0000-0000-0000-000000000000 and convId.domain to the tenant’s domain; use any reasonable filename.

Note: the server treats the asset bytes as application/octet-stream; filetype is used for auditing only.

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

--frontier
Content-Type: application/json
Content-Length: 191

{\"public\":false,\"retention\":\"volatile\",\"convId\":{\"id\":\"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\",\"domain\":\"example.com\"},\"filename\":\"report.pdf\",\"filetype\":\"application/pdf\"}
--frontier
Content-Type: application/octet-stream
Content-Length: 11

Hello Audit
--frontier--
","operationId":"assets-upload","requestBody":{"content":{"multipart/mixed":{"schema":{"$ref":"#/components/schemas/AssetSource"}}},"description":"A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server."},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Asset_Qualified_AssetKey_MzU1MjMxNTA5"}}},"description":"Asset posted","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"incomplete-body","message":"HTTP content-length header does not match body size"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["incomplete-body","invalid-length"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nHTTP content-length header does not match body size (label: `incomplete-body`)\n\nInvalid content length (label: `invalid-length`)"},"413":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"client-error","message":"Asset too large"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["client-error"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Asset too large (label: `client-error`)"}}}},"/assets/{key_domain}/{key}":{"get":{"summary":"Download an asset","description":" [internal route ID: \"assets-download\"]\n\n**Note**: local assets result in a redirect, while remote assets are streamed directly.","operationId":"assets-download","parameters":[{"in":"path","name":"key_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"key","required":true,"schema":{"type":"string"}},{"in":"header","name":"Asset-Token","required":false,"schema":{"type":"string"}},{"in":"query","name":"asset_token","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset returned directly with content type `application/octet-stream`"},"302":{"description":"Asset found","headers":{"Location":{"description":"Asset location","schema":{"format":"url","type":"string"}}}},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key_domain` or `key` or Asset not found (label: `not-found`)\n\nAsset not found (label: `not-found`)"}}},"delete":{"summary":"Delete an asset","description":" [internal route ID: \"assets-delete\"]\n\n**Note**: only local assets can be deleted.","operationId":"assets-delete","parameters":[{"in":"path","name":"key_domain","required":true,"schema":{"type":"string"}},{"in":"path","name":"key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Asset deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"unauthorised","message":"Unauthorised operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["unauthorised"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Unauthorised operation (label: `unauthorised`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Asset not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`key_domain` or `key` not found\n\nAsset not found (label: `not-found`)"}}}},"/await":{"get":{"summary":"Establish websocket connection","description":" [internal route ID: \"await-notifications\"]\n\n","externalDocs":{"description":"RFC 6455","url":"https://datatracker.ietf.org/doc/html/rfc6455"},"operationId":"await-notifications","parameters":[{"description":"Client ID","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"101":{"description":"Connection upgraded."},"426":{"description":"Upgrade required."}}}},"/websocket":{"get":{"summary":"Establish websocket connection","description":" [internal route ID: \"websocket\"]\n\nThis is a temporary copy of await, please do not use it","externalDocs":{"description":"RFC 6455","url":"https://datatracker.ietf.org/doc/html/rfc6455"},"operationId":"websocket","parameters":[{"description":"Client ID","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"101":{"description":"Connection upgraded."},"426":{"description":"Upgrade required."}}}},"/events":{"get":{"summary":"Consume events over a websocket connection","description":" [internal route ID: \"consume-events\"]\n\nThis is the rabbitMQ-based variant of \"await-notifications\"","externalDocs":{"description":"RFC 6455","url":"https://datatracker.ietf.org/doc/html/rfc6455"},"operationId":"consume-events","parameters":[{"description":"Client ID","in":"query","name":"client","required":false,"schema":{"type":"string"}},{"description":"Synchronization marker ID","in":"query","name":"sync_marker","required":false,"schema":{"type":"string"}}],"responses":{"101":{"description":"Connection upgraded."},"426":{"description":"Upgrade required."}}}},"/push/tokens":{"get":{"summary":"List the user's registered push tokens","description":" [internal route ID: \"get-push-tokens\"]\n\n","operationId":"get-push-tokens","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PushTokenList_NDI0Mjc3MzY3"}}},"description":""}}},"post":{"summary":"Register a native push token","description":" [internal route ID: \"register-push-token\"]\n\n","operationId":"register-push-token","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"}}},"description":"Push token registered","headers":{"Location":{"schema":{"type":"string"}}}},"400":{"content":{"application/json":{"schema":{"example":{"code":400,"label":"apns-voip-not-supported","message":"Adding APNS_VOIP tokens is not supported"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["apns-voip-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"apns-voip-not-supported","message":"Adding APNS_VOIP tokens is not supported"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["apns-voip-not-supported"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Adding APNS_VOIP tokens is not supported (label: `apns-voip-not-supported`) or `body`"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"app-not-found","message":"App does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["app-not-found","invalid-token"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"app-not-found","message":"App does not exist"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["app-not-found","invalid-token"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"App does not exist (label: `app-not-found`)\n\nInvalid push token (label: `invalid-token`)"},"413":{"content":{"application/json":{"schema":{"example":{"code":413,"label":"sns-thread-budget-reached","message":"Too many concurrent calls to SNS; is SNS down?"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["sns-thread-budget-reached","token-too-long","metadata-too-long"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":413,"label":"sns-thread-budget-reached","message":"Too many concurrent calls to SNS; is SNS down?"},"properties":{"code":{"enum":[413],"type":"integer"},"label":{"enum":["sns-thread-budget-reached","token-too-long","metadata-too-long"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Too many concurrent calls to SNS; is SNS down? (label: `sns-thread-budget-reached`)\n\nPush token length must be < 8192 for GCM or 400 for APNS (label: `token-too-long`)\n\nTried to add token to endpoint resulting in metadata length > 2048 (label: `metadata-too-long`)"}}}},"/push/tokens/{pid}":{"delete":{"summary":"Unregister a native push token","description":" [internal route ID: \"delete-push-token\"]\n\n","operationId":"delete-push-token","parameters":[{"description":"The push token to delete","in":"path","name":"pid","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Push token unregistered"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Push token not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Push token not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`pid` or Push token not found (label: `not-found`)"}}}},"/notifications/{id}":{"get":{"summary":"Fetch a notification by ID","description":" [internal route ID: \"get-notification-by-id\"]\n\n","operationId":"get-notification-by-id","parameters":[{"description":"Notification ID","in":"path","name":"id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Only return notifications targeted at the given client","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}}},"description":"Notification found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`id` or Some notifications not found (label: `not-found`)"}}}},"/notifications/last":{"get":{"summary":"Fetch the last notification","description":" [internal route ID: \"get-last-notification\"]\n\n","operationId":"get-last-notification","parameters":[{"description":"Only return notifications targeted at the given client","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"}}},"description":"Notification found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Some notifications not found (label: `not-found`)"}}}},"/notifications":{"get":{"summary":"Fetch notifications","description":" [internal route ID: \"get-notifications\"]\n\nSee also: GET /teams/notifications","operationId":"get-notifications","parameters":[{"description":"Only return notifications more recent than this","in":"query","name":"since","required":false,"schema":{"format":"uuid","type":"string"}},{"description":"Only return notifications targeted at the given client","in":"query","name":"client","required":false,"schema":{"type":"string"}},{"description":"Maximum number of notifications to return","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":10000,"minimum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueuedNotificationList_MTU0ODEyNTQ2"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QueuedNotificationList_MTU0ODEyNTQ2"}}},"description":"Notification list"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Some notifications not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Some notifications not found (label: `not-found`)"}}}},"/time":{"get":{"summary":"Get the current server time","description":" [internal route ID: \"get-server-time\"]\n\nReturns the current server time in UTC with seconds precision.","operationId":"get-server-time","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServerTime_LTM4NTI3MzIx"}}},"description":""}}}},"/proxy/giphy/v1/gifs":{},"/proxy/youtube/v3":{},"/proxy/googlemaps/api/staticmap":{},"/proxy/googlemaps/maps/api/geocode":{},"/proxy/spotify/api/token":{},"/proxy/soundcloud/resolve":{},"/proxy/soundcloud/stream":{}},"components":{"schemas":{"VersionInfo_NTEzMTgzNDQ0":{"example":{"development":[17],"domain":"example.com","federation":false,"supported":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17]},"properties":{"development":{"items":{"$ref":"#/components/schemas/VersionNumber_Njk2NzI5Njk1"},"type":"array"},"domain":{"$ref":"#/components/schemas/Domain"},"federation":{"type":"boolean"},"supported":{"items":{"$ref":"#/components/schemas/VersionNumber_Njk2NzI5Njk1"},"type":"array"}},"required":["supported","development","federation","domain"],"type":"object"},"VersionNumber_Njk2NzI5Njk1":{"enum":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17],"type":"integer"},"Domain":{"example":"example.com","type":"string"},"UserProfile_LTQzMTQxMTE1":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"app":{"$ref":"#/components/schemas/AppInfo_MjgwNTkwOTUz"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"contact_status":{"$ref":"#/components/schemas/ContactStatus_LTUzNzk1MzM4"},"deleted":{"type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"expires_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"handle":{"$ref":"#/components/schemas/Handle"},"id":{"$ref":"#/components/schemas/UUID"},"legalhold_status":{"$ref":"#/components/schemas/UserLegalHoldStatus_LTQ2ODA2NTU5"},"name":{"maxLength":128,"minLength":1,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"searchable":{"type":"boolean"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"},"team":{"$ref":"#/components/schemas/UUID"},"text_status":{"maxLength":256,"minLength":1,"type":"string"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"}},"required":["qualified_id","name","accent_id","legalhold_status"],"type":"object"},"UUID":{"description":"The OAuth client's ID","example":"99db9768-04e3-4b5d-9268-831b6a25c4ab","format":"uuid","type":"string"},"Qualified_Id_IdTag_User_LTQ1NTIwNDM1":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","id"],"type":"object"},"KeyMap_Value_MzAxODEwOTgx":{"type":"object"},"Pict_DEPRECATED_USE_ASSETS_INSTEAD":{"items":{"type":"object"},"maxItems":10,"minItems":0,"type":"array"},"AssetKey":{"description":"S3 asset key for an icon image with retention information.","example":"3-1-47de4580-ae51-4650-acbb-d10c028cb0ac","type":"string"},"AssetSize_OTAwMDA3ODY2":{"enum":["preview","complete"],"type":"string"},"MTYxOTI3NjM3":{"enum":["image"],"type":"string"},"Asset_LTIyMjc1NDEz":{"properties":{"key":{"$ref":"#/components/schemas/AssetKey"},"size":{"$ref":"#/components/schemas/AssetSize_OTAwMDA3ODY2"},"type":{"$ref":"#/components/schemas/MTYxOTI3NjM3"}},"required":["key","type"],"type":"object"},"ServiceRef_LTgxMjY3NzAz":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"provider":{"$ref":"#/components/schemas/UUID"}},"required":["id","provider"],"type":"object"},"Handle":{"type":"string"},"UTCTimeMillis":{"description":"The time when the session was created","example":"2021-05-12T10:52:02.671Z","format":"yyyy-mm-ddThh:MM:ss.qqqZ","type":"string"},"Email":{"type":"string"},"UserLegalHoldStatus_LTQ2ODA2NTU5":{"description":"The state of Legal Hold compliance for the member","enum":["enabled","pending","disabled","no_consent"],"type":"string"},"BaseProtocolTag_LTM0MDE1NTEx":{"enum":["proteus","mls"],"type":"string"},"UserType_LTU1OTU4OTM5":{"enum":["regular","app","bot"],"type":"string"},"AppInfo_MjgwNTkwOTUz":{"properties":{"category":{"description":"Category name (if uncertain, pick \"other\")","type":"string"},"description":{"maxLength":300,"minLength":0,"type":"string"}},"required":["category","description"],"type":"object"},"ContactStatusState_LTg2MjAyNzAx":{"enum":["contactable","non-contactable"],"type":"string"},"ContactStatus_LTUzNzk1MzM4":{"properties":{"state":{"$ref":"#/components/schemas/ContactStatusState_LTg2MjAyNzAx"}},"required":["state"],"type":"object"},"EmailUpdate_NjQ5MDg1OTY0":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"ListUsersById_LTQ5MTE3NDc0":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"minItems":1,"type":"array"},"found":{"items":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"},"type":"array"}},"required":["found"],"type":"object"},"ListUsersQuery":{"description":"exactly one of qualified_ids or qualified_handles must be provided.","example":{"qualified_ids":[{"domain":"example.com","id":"00000000-0000-0000-0000-000000000000"}]},"properties":{"qualified_handles":{"items":{"$ref":"#/components/schemas/Qualified_Handle_Nzg0MDE3Nzk4"},"type":"array"},"qualified_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"type":"object"},"Qualified_Handle_Nzg0MDE3Nzk4":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"handle":{"$ref":"#/components/schemas/Handle"}},"required":["domain","handle"],"type":"object"},"SendVerificationCode_MjgxNDgxODE2":{"properties":{"action":{"$ref":"#/components/schemas/VerificationAction_LTU0MzYxNzUz"},"email":{"$ref":"#/components/schemas/Email"}},"required":["action","email"],"type":"object"},"VerificationAction_LTU0MzYxNzUz":{"enum":["create_scim_token","login","delete_team"],"type":"string"},"RichInfoAssocList":{"description":"json object with case-insensitive fields.","properties":{"fields":{"items":{"$ref":"#/components/schemas/RichField_LTgwMzc0MTg2"},"type":"array"},"version":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["version","fields"],"type":"object"},"RichField_LTgwMzc0MTg2":{"properties":{"type":{"type":"string"},"value":{"type":"string"}},"required":["type","value"],"type":"object"},"SetSearchable_NDAxODAxODI5":{"properties":{"set_searchable":{"type":"boolean"}},"required":["set_searchable"],"type":"object"},"User_NjA4OTQwMTQ4":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"deleted":{"type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"email_unvalidated":{"$ref":"#/components/schemas/Email"},"expires_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"handle":{"$ref":"#/components/schemas/Handle"},"id":{"$ref":"#/components/schemas/UUID"},"locale":{"$ref":"#/components/schemas/Locale"},"managed_by":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"name":{"maxLength":128,"minLength":1,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"searchable":{"type":"boolean"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"sso_id":{"$ref":"#/components/schemas/UserSSOId"},"status":{"$ref":"#/components/schemas/AccountStatus_NzkzNDU1ODU5"},"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"},"team":{"$ref":"#/components/schemas/UUID"},"text_status":{"maxLength":256,"minLength":1,"type":"string"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"}},"required":["qualified_id","type","name","accent_id","status","locale"],"type":"object"},"UserSSOId":{"properties":{"scim_external_id":{"type":"string"},"subject":{"type":"string"},"tenant":{"type":"string"}},"type":"object"},"AccountStatus_NzkzNDU1ODU5":{"enum":["active","suspended","deleted","ephemeral","pending-invitation"],"type":"string"},"Locale":{"type":"string"},"ManagedBy_NTI0ODc0NTQx":{"enum":["wire","scim"],"type":"string"},"DeletionCodeTimeout_LTU1MTk0NDI3":{"properties":{"expires_in":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["expires_in"],"type":"object"},"DeleteUser_NjE0MjE2Mjkz":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"UserUpdate_MjQ4NTEwOTQz":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"name":{"maxLength":128,"minLength":1,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"text_status":{"maxLength":256,"minLength":1,"type":"string"}},"type":"object"},"PasswordChange_MTgzMDM2NTY2":{"description":"Data to change a password. The old password is required if a password already exists.","properties":{"new_password":{"maxLength":1024,"minLength":8,"type":"string"},"old_password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["new_password"],"type":"object"},"LocaleUpdate_LTgzNjgyOTEw":{"properties":{"locale":{"$ref":"#/components/schemas/Locale"}},"required":["locale"],"type":"object"},"HandleUpdate_NTI4NDk1OTAx":{"properties":{"handle":{"type":"string"}},"required":["handle"],"type":"object"},"SupportedProtocolUpdate_LTE3Njk3MDM4":{"properties":{"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"}},"required":["supported_protocols"],"type":"object"},"CreateUserTeam_MzI4NDQ1Mzkw":{"properties":{"team_id":{"$ref":"#/components/schemas/UUID"},"team_name":{"type":"string"}},"required":["team_id","team_name"],"type":"object"},"BindingNewTeamUser_LTY0MDQxMDEw":{"properties":{"currency":{"$ref":"#/components/schemas/Alpha_LTE4NDUxNDQ4"},"icon":{"$ref":"#/components/schemas/Icon"},"icon_key":{"description":"The decryption key for the team icon S3 asset","maxLength":256,"minLength":1,"type":"string"},"name":{"description":"team name","maxLength":256,"minLength":1,"type":"string"}},"required":["name","icon"],"type":"object"},"Icon":{"description":"S3 asset key for an icon image with retention information. Allows special value 'default'.","example":"3-1-47de4580-ae51-4650-acbb-d10c028cb0ac","type":"string"},"Alpha_LTE4NDUxNDQ4":{"description":"ISO 4217 alphabetic codes. This is only stored by the backend, not processed. It can be removed once billing supports currency changes after team creation.","enum":["AED","AFN","ALL","AMD","ANG","AOA","ARS","AUD","AWG","AZN","BAM","BBD","BDT","BGN","BHD","BIF","BMD","BND","BOB","BOV","BRL","BSD","BTN","BWP","BYN","BZD","CAD","CDF","CHE","CHF","CHW","CLF","CLP","CNY","COP","COU","CRC","CUC","CUP","CVE","CZK","DJF","DKK","DOP","DZD","EGP","ERN","ETB","EUR","FJD","FKP","GBP","GEL","GHS","GIP","GMD","GNF","GTQ","GYD","HKD","HNL","HRK","HTG","HUF","IDR","ILS","INR","IQD","IRR","ISK","JMD","JOD","JPY","KES","KGS","KHR","KMF","KPW","KRW","KWD","KYD","KZT","LAK","LBP","LKR","LRD","LSL","LYD","MAD","MDL","MGA","MKD","MMK","MNT","MOP","MRO","MUR","MVR","MWK","MXN","MXV","MYR","MZN","NAD","NGN","NIO","NOK","NPR","NZD","OMR","PAB","PEN","PGK","PHP","PKR","PLN","PYG","QAR","RON","RSD","RUB","RWF","SAR","SBD","SCR","SDG","SEK","SGD","SHP","SLL","SOS","SRD","SSP","STD","SVC","SYP","SZL","THB","TJS","TMT","TND","TOP","TRY","TTD","TWD","TZS","UAH","UGX","USD","USN","UYI","UYU","UZS","VEF","VND","VUV","WST","XAF","XAG","XAU","XBA","XBB","XBC","XBD","XCD","XDR","XOF","XPD","XPF","XPT","XSU","XTS","XUA","XXX","YER","ZAR","ZMW","ZWL"],"example":"EUR","type":"string"},"NewUser_PlainTextPassword_8_LTI4MzI5NzQx":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"email":{"$ref":"#/components/schemas/Email"},"email_code":{"$ref":"#/components/schemas/ASCII"},"expires_in":{"maximum":604800,"minimum":1,"type":"integer"},"invitation_code":{"$ref":"#/components/schemas/ASCII"},"label":{"type":"string"},"locale":{"$ref":"#/components/schemas/Locale"},"managed_by":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"name":{"maxLength":128,"minLength":1,"type":"string"},"password":{"maxLength":1024,"minLength":8,"type":"string"},"picture":{"$ref":"#/components/schemas/Pict_DEPRECATED_USE_ASSETS_INSTEAD"},"sso_id":{"$ref":"#/components/schemas/UserSSOId"},"supported_protocols":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array"},"team":{"$ref":"#/components/schemas/BindingNewTeamUser_LTY0MDQxMDEw"},"team_code":{"$ref":"#/components/schemas/ASCII"},"team_id":{"$ref":"#/components/schemas/UUID"},"uuid":{"$ref":"#/components/schemas/UUID"}},"required":["name"],"type":"object"},"ASCII":{"example":"aGVsbG8","type":"string"},"VerifyDeleteUser_Njc1NDQ1MDIy":{"description":"Data for verifying an account deletion.","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"}},"required":["key","code"],"type":"object"},"ActivationResponse_LTIyOTY5NDE3":{"description":"Response body of a successful activation request","properties":{"email":{"$ref":"#/components/schemas/Email"},"first":{"description":"Whether this is the first successful activation (i.e. account activation).","type":"boolean"},"sso_id":{"$ref":"#/components/schemas/UserSSOId"}},"type":"object"},"Activate_MzUzNzIxODUw":{"description":"Data for an activation request.","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"dryrun":{"description":"At least one of key, email, or phone has to be present while key takes precedence over email, and email takes precedence over phone. Whether to perform a dryrun, i.e. to only check whether activation would succeed. Dry-runs never issue access cookies or tokens on success but failures still count towards the maximum failure count.","type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"key":{"$ref":"#/components/schemas/ASCII"}},"required":["code","dryrun"],"type":"object"},"SendActivationCode_LTgyNDAxNzEy":{"description":"Data for requesting an email code to be sent. 'email' must be present.","properties":{"email":{"$ref":"#/components/schemas/Email"},"locale":{"$ref":"#/components/schemas/Locale"}},"required":["email"],"type":"object"},"NewPasswordReset_LTEyNzAxMTcy":{"description":"Data to initiate a password reset","properties":{"email":{"$ref":"#/components/schemas/Email"},"phone":{"description":"Email","type":"string"}},"type":"object"},"CompletePasswordReset_NDcyMjY5OTc4":{"description":"Data to complete a password reset","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"email":{"$ref":"#/components/schemas/Email"},"key":{"$ref":"#/components/schemas/ASCII"},"password":{"description":"New password (6 - 1024 characters)","maxLength":1024,"minLength":8,"type":"string"},"phone":{"$ref":"#/components/schemas/PhoneNumber"}},"required":["code","password"],"type":"object"},"PhoneNumber":{"description":"A known phone number with a pending password reset.","type":"string"},"PubClient":{"properties":{"class":{"$ref":"#/components/schemas/ClientClass_NjE3MDgwNzcx"},"id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"}},"required":["id"],"type":"object"},"ClientClass_NjE3MDgwNzcx":{"enum":["phone","tablet","desktop","legalhold"],"type":"string"},"QualifiedUserMap_Set_PubClient":{"additionalProperties":{"$ref":"#/components/schemas/UserMap_Set_PubClient"},"description":"Map of Domain to (UserMap (Set_PubClient)).","example":{"domain1.example.com":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":[{"class":"legalhold","id":"d0"}]}},"type":"object"},"UserMap_Set_PubClient":{"additionalProperties":{"items":{"$ref":"#/components/schemas/PubClient"},"type":"array","uniqueItems":true},"description":"Map of UserId to (Set PubClient)","example":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":[{"class":"legalhold","id":"d0"}]},"type":"object"},"LimitedQualifiedUserIdList_500":{"properties":{"qualified_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"}},"required":["qualified_users"],"type":"object"},"ClientPrekey_LTcyODUzMTcw":{"properties":{"client":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"prekey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"}},"required":["client","prekey"],"type":"object"},"UncheckedPrekeyBundle_LTU1MzQzOTgy":{"properties":{"id":{"maximum":65535,"minimum":0,"type":"integer"},"key":{"type":"string"}},"required":["id","key"],"type":"object"},"PrekeyBundle_MzgzOTk4MjYz":{"properties":{"clients":{"items":{"$ref":"#/components/schemas/ClientPrekey_LTcyODUzMTcw"},"type":"array"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","clients"],"type":"object"},"QualifiedUserClientPrekeyMapV4_MzcxOTAxMTYy":{"properties":{"failed_to_list":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"qualified_user_client_prekeys":{"additionalProperties":{"$ref":"#/components/schemas/UserClientPrekeyMap"},"type":"object"}},"required":["qualified_user_client_prekeys"],"type":"object"},"UserClientPrekeyMap":{"additionalProperties":{"additionalProperties":{"properties":{"id":{"maximum":65535,"minimum":0,"type":"integer"},"key":{"type":"string"}},"required":["id","key"],"type":"object"},"type":"object"},"example":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":{"44901fb0712e588f":{"id":1,"key":"pQABAQECoQBYIOjl7hw0D8YRNq..."}}},"type":"object"},"QualifiedUserClients":{"additionalProperties":{"additionalProperties":{"items":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"type":"array"},"type":"object"},"description":"Map of Domain to UserClients","example":{"domain1.example.com":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":["60f85e4b15ad3786","6e323ab31554353b"]}},"type":"object"},"Client_MTM1OTcwOTQ1":{"properties":{"capabilities":{"$ref":"#/components/schemas/ClientCapabilityList"},"class":{"$ref":"#/components/schemas/ClientClass_NjE3MDgwNzcx"},"cookie":{"type":"string"},"id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"label":{"type":"string"},"last_active":{"$ref":"#/components/schemas/UTCTime"},"mls_public_keys":{"$ref":"#/components/schemas/MLSPublicKeys"},"model":{"type":"string"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"},"type":{"$ref":"#/components/schemas/ClientType_MjQ0OTQwMzcw"}},"required":["id","type","time"],"type":"object"},"ClientType_MjQ0OTQwMzcw":{"enum":["temporary","permanent","legalhold"],"type":"string"},"ClientCapability_MTY2NDAzMjM3":{"enum":["legalhold-implicit-consent","consumable-notifications"],"type":"string"},"ClientCapabilityList":{"items":{"$ref":"#/components/schemas/ClientCapability_MTY2NDAzMjM3"},"type":"array"},"Base64ByteString":{"example":"ZXhhbXBsZQo=","type":"string"},"MLSPublicKeys":{"additionalProperties":{"example":"ZXhhbXBsZQo=","type":"string"},"description":"Mapping from signature scheme (tags) to public key data","example":{"ecdsa_secp256r1_sha256":"ZXhhbXBsZQo=","ecdsa_secp384r1_sha384":"ZXhhbXBsZQo=","ecdsa_secp521r1_sha512":"ZXhhbXBsZQo=","ed25519":"ZXhhbXBsZQo="},"type":"object"},"UTCTime":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"},"NewClient_ODg1NjY4Njgy":{"properties":{"capabilities":{"$ref":"#/components/schemas/ClientCapabilityList"},"class":{"$ref":"#/components/schemas/ClientClass_NjE3MDgwNzcx"},"cookie":{"description":"The cookie label, i.e. the label used when logging in.","type":"string"},"label":{"type":"string"},"lastkey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"mls_public_keys":{"$ref":"#/components/schemas/MLSPublicKeys"},"model":{"type":"string"},"password":{"description":"The password of the authenticated user for verification. Note: Required for registration of the 2nd, 3rd, ... client.","maxLength":1024,"minLength":6,"type":"string"},"prekeys":{"description":"Prekeys for other clients to establish OTR sessions.","items":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"type":"array"},"type":{"$ref":"#/components/schemas/ClientType_MjQ0OTQwMzcw"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"required":["prekeys","lastkey","type"],"type":"object"},"UpdateClient_NzU5MjA4MzI1":{"properties":{"capabilities":{"$ref":"#/components/schemas/ClientCapabilityList"},"label":{"description":"A new name for this client.","type":"string"},"lastkey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"mls_public_keys":{"$ref":"#/components/schemas/MLSPublicKeys"},"prekeys":{"description":"New prekeys for other clients to establish OTR sessions.","items":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"type":"array"}},"type":"object"},"RmClient_MTQ5OTI2MDY3":{"properties":{"password":{"description":"The password of the authenticated user for verification. The password is not required for deleting temporary clients. or if the user has only SAML credentials.","maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"DPoPAccessTokenResponse_LTgyODU5MDE3":{"properties":{"expires_in":{"format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"token":{"$ref":"#/components/schemas/DPoPAccessToken"},"type":{"$ref":"#/components/schemas/AccessTokenType_LTgyOTY0NDE5"}},"required":["token","type","expires_in"],"type":"object"},"DPoPAccessToken":{"type":"string"},"AccessTokenType_LTgyOTY0NDE5":{"enum":["DPoP"],"type":"string"},"UserConnection_LTY3NzU1ODg0":{"properties":{"conversation":{"$ref":"#/components/schemas/UUID"},"from":{"$ref":"#/components/schemas/UUID"},"last_update":{"$ref":"#/components/schemas/UTCTimeMillis"},"qualified_conversation":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_to":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"status":{"$ref":"#/components/schemas/Relation_LTE4OTU5MTk4"},"to":{"$ref":"#/components/schemas/UUID"}},"required":["from","qualified_to","status","last_update"],"type":"object"},"Relation_LTE4OTU5MTk4":{"enum":["accepted","blocked","pending","ignored","sent","cancelled","missing-legalhold-consent"],"type":"string"},"Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","id"],"type":"object"},"MultiTabePg_ConcsLOrRmU_MTgwMzU4OTU5":{"properties":{"connections":{"items":{"$ref":"#/components/schemas/UserConnection_LTY3NzU1ODg0"},"type":"array"},"has_more":{"type":"boolean"},"paging_state":{"$ref":"#/components/schemas/Connections_PagingState"}},"required":["connections","has_more","paging_state"],"type":"object"},"Connections_PagingState":{"type":"string"},"GetMuliTabPgRqs_ConcLOrm501_LTIwMjA4NzYw":{"description":"A request to list some or all of a user's Connections, including remote ones","properties":{"paging_state":{"$ref":"#/components/schemas/Connections_PagingState"},"size":{"description":"optional, must be <= 500, defaults to 100.","format":"int32","maximum":500,"minimum":1,"type":"integer"}},"type":"object"},"ConnectionUpdate_LTU3MTA1OTA5":{"properties":{"status":{"$ref":"#/components/schemas/Relation_LTE4OTU5MTk4"}},"required":["status"],"type":"object"},"SearchResult_Contact_OTExNzg4MTE0":{"properties":{"documents":{"description":"List of contacts found","items":{"$ref":"#/components/schemas/Contact_LTcwODE3Mjc5"},"type":"array"},"found":{"description":"Total number of hits","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"has_more":{"description":"Indicates whether there are more results to be fetched","type":"boolean"},"paging_state":{"$ref":"#/components/schemas/PagingState"},"returned":{"description":"Total number of hits returned","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"search_policy":{"$ref":"#/components/schemas/FederatedUserSearchPolicy_MzkwODA4MTM3"},"took":{"description":"Search time in ms","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["found","returned","took","documents","search_policy"],"type":"object"},"Contact_LTcwODE3Mjc5":{"description":"Contact discovered through search","properties":{"accent_id":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"handle":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"}},"required":["qualified_id","name","type"],"type":"object"},"FederatedUserSearchPolicy_MzkwODA4MTM3":{"description":"Search policy that was applied when searching for users","enum":["no_search","exact_handle_search","full_search"],"type":"string"},"PagingState":{"description":"Paging state that should be supplied to retrieve the next page of results","type":"string"},"PropertyValue":{"description":"An arbitrary JSON value for a property"},"PropertyKeysAndValues":{"type":"object"},"KeyPackageUpload_NTQ2Mjk2NzEx":{"properties":{"key_packages":{"items":{"$ref":"#/components/schemas/KeyPackage"},"type":"array"}},"required":["key_packages"],"type":"object"},"KeyPackage":{"example":"a2V5IHBhY2thZ2UgZGF0YQo=","type":"string"},"KeyPackageBundle_MjU2MjY0MDU2":{"properties":{"key_packages":{"items":{"$ref":"#/components/schemas/KeyPackageBundleEntry_NDQ2MzQ2MzMz"},"type":"array"}},"required":["key_packages"],"type":"object"},"KeyPackageRef":{"example":"ZXhhbXBsZQo=","type":"string"},"KeyPackageBundleEntry_NDQ2MzQ2MzMz":{"properties":{"client":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"domain":{"$ref":"#/components/schemas/Domain"},"key_package":{"$ref":"#/components/schemas/KeyPackage"},"key_package_ref":{"$ref":"#/components/schemas/KeyPackageRef"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["domain","user","client","key_package_ref","key_package"],"type":"object"},"KeyPackageCount_LTYwNDg5MDcz":{"properties":{"count":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["count"],"type":"object"},"DeleteKeyPackages_LTQxNTcxNjY3":{"properties":{"key_packages":{"items":{"$ref":"#/components/schemas/KeyPackageRef"},"maxItems":1000,"minItems":1,"type":"array"}},"required":["key_packages"],"type":"object"},"CheckHandles_LTc0OTkxMzAx":{"properties":{"handles":{"items":{"type":"string"},"maxItems":50,"minItems":1,"type":"array"},"return":{"maximum":10,"minimum":1,"type":"integer"}},"required":["handles","return"],"type":"object"},"SearchResult_TeamContact_LTE0NjQ0NzMw":{"properties":{"documents":{"description":"List of contacts found","items":{"$ref":"#/components/schemas/TeamContact_LTI5MTIxODc0"},"type":"array"},"found":{"description":"Total number of hits","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"has_more":{"description":"Indicates whether there are more results to be fetched","type":"boolean"},"paging_state":{"$ref":"#/components/schemas/PagingState"},"returned":{"description":"Total number of hits returned","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"search_policy":{"$ref":"#/components/schemas/FederatedUserSearchPolicy_MzkwODA4MTM3"},"took":{"description":"Search time in ms","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["found","returned","took","documents","search_policy"],"type":"object"},"Role_LTIzMjAzMjky":{"description":"Role of the invited user","enum":["owner","admin","member","partner"],"type":"string"},"Sso_LTg1MDM5ODQ3":{"properties":{"issuer":{"type":"string"},"nameid":{"type":"string"}},"required":["issuer","nameid"],"type":"object"},"TeamContact_LTI5MTIxODc0":{"properties":{"accent_id":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"email":{"$ref":"#/components/schemas/Email"},"email_unvalidated":{"$ref":"#/components/schemas/Email"},"handle":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"managed_by":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"name":{"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"},"saml_idp":{"type":"string"},"scim_external_id":{"type":"string"},"searchable":{"type":"boolean"},"sso":{"$ref":"#/components/schemas/Sso_LTg1MDM5ODQ3"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/UserType_LTU1OTU4OTM5"},"user_groups":{"description":"List of user group ids the user is a member of","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["id","type","name","user_groups","searchable"],"type":"object"},"AccessToken_ODIyMTczMjMw":{"properties":{"access_token":{"description":"The opaque access token string","type":"string"},"expires_in":{"description":"The number of seconds this token is valid","type":"integer"},"token_type":{"$ref":"#/components/schemas/TokenType_NTkyMzk4MjIz"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","access_token","token_type","expires_in"],"type":"object"},"TokenType_NTkyMzk4MjIz":{"enum":["Bearer"],"type":"string"},"Login_LTgyNTIzMTM1":{"properties":{"email":{"$ref":"#/components/schemas/Email"},"handle":{"$ref":"#/components/schemas/Handle"},"label":{"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"required":["password"],"type":"object"},"CookieList_LTM4MzYwNzAz":{"description":"List of cookie information","properties":{"cookies":{"items":{"$ref":"#/components/schemas/Cookie_LTkyMDA3OTI5"},"type":"array"}},"required":["cookies"],"type":"object"},"CookieType_LTE0MjczNzY3":{"enum":["session","persistent"],"type":"string"},"Cookie_LTkyMDA3OTI5":{"properties":{"created":{"$ref":"#/components/schemas/UTCTime"},"expires":{"$ref":"#/components/schemas/UTCTime"},"id":{"format":"int32","maximum":4294967295,"minimum":0,"type":"integer"},"label":{"type":"string"},"successor":{"format":"int32","maximum":4294967295,"minimum":0,"type":"integer"},"type":{"$ref":"#/components/schemas/CookieType_LTE0MjczNzY3"}},"required":["id","type","created","expires"],"type":"object"},"RemoveCookies_OTYwMTI0NDMy":{"description":"Data required to remove cookies","properties":{"ids":{"description":"A list of cookie IDs to revoke","items":{"format":"int32","maximum":4294967295,"minimum":0,"type":"integer"},"type":"array"},"labels":{"description":"A list of cookie labels for which to revoke the cookies","items":{"type":"string"},"type":"array"},"password":{"description":"The user's password","maxLength":1024,"minLength":6,"type":"string"}},"required":["password"],"type":"object"},"RTCConfiguration_LTIwOTc4OTk0":{"description":"A subset of the WebRTC 'RTCConfiguration' dictionary","properties":{"ice_servers":{"description":"Array of 'RTCIceServer' objects","items":{"$ref":"#/components/schemas/RTCIceServer_LTY1NzExODA0"},"minItems":1,"type":"array"},"is_federating":{"description":"True if the client should connect to an SFT in the sft_servers_all and request it to federate","type":"boolean"},"sft_servers":{"description":"Array of 'SFTServer' objects (optional)","items":{"$ref":"#/components/schemas/SFTServer_NDQ0NDkwNDE2"},"minItems":1,"type":"array"},"sft_servers_all":{"description":"Array of all SFT servers","items":{"$ref":"#/components/schemas/AuthSFTServer_LTY5MzcyOTE0"},"type":"array"},"ttl":{"description":"Number of seconds after which the configuration should be refreshed (advisory)","format":"int32","maximum":4294967295,"minimum":0,"type":"integer"}},"required":["ice_servers","ttl"],"type":"object"},"TurnURI":{"type":"string"},"TurnUsername":{"description":"Username to use for authenticating against the given TURN servers","type":"string"},"RTCIceServer_LTY1NzExODA0":{"description":"A subset of the WebRTC 'RTCIceServer' object","properties":{"credential":{"$ref":"#/components/schemas/ASCII"},"urls":{"description":"Array of TURN server addresses of the form 'turn::'","items":{"$ref":"#/components/schemas/TurnURI"},"minItems":1,"type":"array"},"username":{"$ref":"#/components/schemas/TurnUsername"}},"required":["urls","username","credential"],"type":"object"},"HttpsUrl":{"example":"https://example.com","type":"string"},"SFTServer_NDQ0NDkwNDE2":{"description":"Inspired by WebRTC 'RTCIceServer' object, contains details of SFT servers","properties":{"urls":{"description":"Array containing exactly one SFT server address of the form 'https://:'","items":{"$ref":"#/components/schemas/HttpsUrl"},"type":"array"}},"required":["urls"],"type":"object"},"SFTUsername":{"description":"String containing the SFT username","type":"string"},"AuthSFTServer_LTY5MzcyOTE0":{"description":"Inspired by WebRTC 'RTCIceServer' object, contains details of SFT servers","properties":{"credential":{"$ref":"#/components/schemas/ASCII"},"urls":{"description":"Array containing exactly one SFT server address of the form 'https://:'","items":{"$ref":"#/components/schemas/HttpsUrl"},"type":"array"},"username":{"$ref":"#/components/schemas/SFTUsername"}},"required":["urls"],"type":"object"},"Invitation_NTkzMDYwODc1":{"description":"An invitation to join a team on Wire. If invitee is invited from an existing personal account, inviter email is included.","properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"email":{"$ref":"#/components/schemas/Email"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"description":"Name of the invitee (1 - 128 characters)","maxLength":128,"minLength":1,"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"},"team":{"$ref":"#/components/schemas/UUID"},"url":{"$ref":"#/components/schemas/URIRef_Absolute"}},"required":["team","id","created_at","email"],"type":"object"},"URIRef_Absolute":{"description":"URL of the invitation link to be sent to the invitee","type":"string"},"InvitationRequest_LTcyMDIzNDc0":{"description":"A request to join a team on Wire.","properties":{"allow_existing":{"description":"Whether invitations to existing users are allowed.","type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"locale":{"$ref":"#/components/schemas/Locale"},"name":{"description":"Name of the invitee (1 - 128 characters).","maxLength":128,"minLength":1,"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"}},"required":["email"],"type":"object"},"InvitationList_ODk4NTQxODc3":{"description":"A list of sent team invitations.","properties":{"has_more":{"description":"Indicator that the server has more invitations than returned.","type":"boolean"},"invitations":{"items":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"},"type":"array"}},"required":["invitations","has_more"],"type":"object"},"InvitationUserView_LTUyMTE3Nzkz":{"properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"created_by_email":{"$ref":"#/components/schemas/Email"},"email":{"$ref":"#/components/schemas/Email"},"id":{"$ref":"#/components/schemas/UUID"},"managed_by":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"name":{"description":"Name of the invitee (1 - 128 characters)","maxLength":128,"minLength":1,"type":"string"},"role":{"$ref":"#/components/schemas/Role_LTIzMjAzMjky"},"team":{"$ref":"#/components/schemas/UUID"},"url":{"$ref":"#/components/schemas/URIRef_Absolute"}},"required":["team","id","created_at","email"],"type":"object"},"TeamSize_LTMzMzk2MTk1":{"description":"Team member counts broken down by user type.","properties":{"teamSize":{"description":"Total team members (teamSizeRegulars + teamSizeApps).","exclusiveMinimum":false,"minimum":0,"type":"integer"},"teamSizeApps":{"description":"Number of apps in team.","exclusiveMinimum":false,"minimum":0,"type":"integer"},"teamSizeRegulars":{"description":"Number of regular users in team.","exclusiveMinimum":false,"minimum":0,"type":"integer"}},"required":["teamSizeRegulars","teamSizeApps"],"type":"object"},"AcceptTeamInvitation_Nzg5NzI3MjA2":{"description":"Accept an invitation to join a team on Wire.","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"password":{"description":"The user account password.","maxLength":1024,"minLength":6,"type":"string"}},"required":["code","password"],"type":"object"},"SystemSettingsPublic_LTgwNTMxNjU2":{"properties":{"nomadProfiles":{"description":"Whether Nomad client profiles are enabled; null or absence means not enabled.","type":"boolean"},"setRestrictUserCreation":{"description":"Do not allow certain user creation flows","type":"boolean"}},"required":["setRestrictUserCreation"],"type":"object"},"SystemSettings_ODU3MDk5MTA3":{"properties":{"nomadProfiles":{"description":"Whether Nomad client profiles are enabled; null or absence means not enabled.","type":"boolean"},"setEnableMls":{"description":"Whether MLS is enabled or not","type":"boolean"},"setRestrictUserCreation":{"description":"Do not allow certain user creation flows","type":"boolean"}},"required":["setRestrictUserCreation","setEnableMls"],"type":"object"},"OAuthClient_NzExMTI5NTIy":{"properties":{"application_name":{"maxLength":256,"minLength":6,"type":"string"},"client_id":{"$ref":"#/components/schemas/UUID"},"redirect_url":{"$ref":"#/components/schemas/RedirectUrl"}},"required":["client_id","application_name","redirect_url"],"type":"object"},"RedirectUrl":{"description":"The URL must match the URL that was used to generate the authorization code.","type":"string"},"CreateOAuthAuthorizationCodeRequest_LTQ3NzI1Mjkz":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"code_challenge":{"$ref":"#/components/schemas/OAuthCodeChallenge"},"code_challenge_method":{"$ref":"#/components/schemas/CodeChallengeMethod_NTIxNzk0NDgw"},"redirect_uri":{"$ref":"#/components/schemas/RedirectUrl"},"response_type":{"$ref":"#/components/schemas/OAuthResponseType_ODI2Mjg3NzQx"},"scope":{"description":"The scopes which are requested to get authorization for, separated by a space","type":"string"},"state":{"description":"An opaque value used by the client to maintain state between the request and callback. The authorization server includes this value when redirecting the user-agent back to the client. The parameter SHOULD be used for preventing cross-site request forgery","type":"string"}},"required":["client_id","scope","response_type","redirect_uri","state","code_challenge_method","code_challenge"],"type":"object"},"OAuthResponseType_ODI2Mjg3NzQx":{"description":"Indicates which authorization flow to use. Use `code` for authorization code flow.","enum":["code"],"type":"string"},"CodeChallengeMethod_NTIxNzk0NDgw":{"description":"The method used to encode the code challenge. Only `S256` is supported.","enum":["S256"],"type":"string"},"OAuthCodeChallenge":{"description":"Generated by the client from the code verifier (unpadded base64url-encoded SHA256 hash of the code verifier)","type":"string"},"OAuthAccessTokenResponse_NzEwOTI4NjQ0":{"properties":{"access_token":{"description":"The access token, which has a relatively short lifetime","type":"string"},"expires_in":{"description":"The lifetime of the access token in seconds","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"refresh_token":{"description":"The refresh token, which has a relatively long lifetime, and can be used to obtain a new access token","type":"string"},"token_type":{"$ref":"#/components/schemas/OAuthAccessTokenType_MjU3ODI0NDIw"}},"required":["access_token","token_type","expires_in","refresh_token"],"type":"object"},"OAuthAccessTokenType_MjU3ODI0NDIw":{"description":"The type of the access token. Currently only `Bearer` is supported.","enum":["Bearer"],"type":"string"},"Either_OAuthAccessTokenRequest_OAuthRefreshAccessTokenRequest":{"oneOf":[{"properties":{"Left":{"$ref":"#/components/schemas/OAuthAccessTokenRequest_LTYyNTcyMzI4"}},"required":["Left"],"title":"Left","type":"object"},{"properties":{"Right":{"$ref":"#/components/schemas/OAuthRefreshAccessTokenRequest_LTUwNjA3OTI1"}},"required":["Right"],"title":"Right","type":"object"}]},"OAuthAccessTokenRequest_LTYyNTcyMzI4":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"code":{"$ref":"#/components/schemas/OAuthAuthorizationCode"},"code_verifier":{"description":"The code verifier to complete the code challenge","maxLength":128,"minLength":43,"type":"string"},"grant_type":{"$ref":"#/components/schemas/OAuthGrantType_LTIxODA5NDIw"},"redirect_uri":{"$ref":"#/components/schemas/RedirectUrl"}},"required":["grant_type","client_id","code_verifier","code","redirect_uri"],"type":"object"},"OAuthGrantType_LTIxODA5NDIw":{"description":"Indicates which authorization flow to use. Use `authorization_code` for authorization code flow.","enum":["authorization_code","refresh_token"],"type":"string"},"OAuthAuthorizationCode":{"description":"The authorization code","type":"string"},"OAuthRefreshAccessTokenRequest_LTUwNjA3OTI1":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"grant_type":{"$ref":"#/components/schemas/OAuthGrantType_LTIxODA5NDIw"},"refresh_token":{"description":"The refresh token","type":"string"}},"required":["grant_type","client_id","refresh_token"],"type":"object"},"OAuthRevokeRefreshTokenRequest_MjA1MDg4MzQ4":{"properties":{"client_id":{"$ref":"#/components/schemas/UUID"},"refresh_token":{"description":"The refresh token","type":"string"}},"required":["client_id","refresh_token"],"type":"object"},"OAuthApplication_Mjk5NTUxNjA1":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"name":{"description":"The OAuth client's name","maxLength":256,"minLength":6,"type":"string"},"sessions":{"description":"The OAuth client's sessions","items":{"$ref":"#/components/schemas/OAuthSession_LTQxOTIxNTMy"},"type":"array"}},"required":["id","name","sessions"],"type":"object"},"OAuthSession_LTQxOTIxNTMy":{"properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"refresh_token_id":{"$ref":"#/components/schemas/UUID"}},"required":["refresh_token_id","created_at"],"type":"object"},"PasswordReqBody_LTcxMzE3ODE3":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"AddBotResponse_ODA5MzA2NTA1":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"client":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"event":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"}},"required":["id","client","name","accent_id","assets","event"],"type":"object"},"EventType_LTQ3NTQyNDYz":{"enum":["conversation.member-join","conversation.member-leave","conversation.member-update","conversation.rename","conversation.access-update","conversation.receipt-mode-update","conversation.message-timer-update","conversation.code-update","conversation.code-delete","conversation.create","conversation.create-meeting","conversation.delete","conversation.delete-meeting","conversation.mls-reset","conversation.connect-request","conversation.typing","conversation.otr-message-add","conversation.mls-message-add","conversation.mls-welcome","conversation.protocol-update","conversation.add-permission-update","conversation.history-update","conversation.adminless-reminder"],"type":"string"},"RoleName":{"description":"Role name, between 2 and 128 chars, 'wire_' prefix is reserved for roles designed by Wire (i.e., no custom roles can have the same prefix)","type":"string"},"SimpleMember_NTY5MTcxMzcx":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"id":{"$ref":"#/components/schemas/UUID"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"}},"required":["qualified_id"],"type":"object"},"JoinType_LTY4MDg2MzA5":{"enum":["external_add","internal_add"],"type":"string"},"MembersJoin_LTg0MDc1NjQ3":{"properties":{"add_type":{"$ref":"#/components/schemas/JoinType_LTY4MDg2MzA5"},"user_ids":{"deprecated":true,"description":"deprecated","items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"users":{"items":{"$ref":"#/components/schemas/SimpleMember_NTY5MTcxMzcx"},"type":"array"}},"required":["users","add_type"],"type":"object"},"EdMemberLeftReason_OTAyMDA4NzEw":{"enum":["left","user-deleted","removed"],"type":"string"},"EdMemberLeftReason_QualifiedUserIdList_LTYyOTg2OTQ1":{"properties":{"qualified_user_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"reason":{"$ref":"#/components/schemas/EdMemberLeftReason_OTAyMDA4NzEw"},"user_ids":{"deprecated":true,"description":"Deprecated, use qualified_user_ids","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["reason","qualified_user_ids","user_ids"],"type":"object"},"MemberUpdateData_LTc3Nzc3NTEy":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"qualified_target":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"target":{"$ref":"#/components/schemas/UUID"}},"required":["qualified_target"],"type":"object"},"ConversationRename_ODkwODg1MzQ0":{"properties":{"name":{"description":"The new conversation name","type":"string"}},"required":["name"],"type":"object"},"Access_NjkyMzE5ODc0":{"description":"How users can join conversations","enum":["private","invite","link","code"],"type":"string"},"AccessRoleLegacy_LTYwOTAxMDI1":{"deprecated":true,"description":"Deprecated, please use access_role_v2","enum":["private","team","activated","non_activated"],"type":"string"},"AccessRole_Mzk3MDYzMzcw":{"description":"Which users/services can join conversations. This replaces legacy access roles and allows a more fine grained configuration of access roles, and in particular a separation of guest and services access.\n\nThis field is optional. If it is not present, the default will be `[team_member, non_team_member, service]`. Please note that an empty list is not allowed when creating a new conversation.","enum":["team_member","non_team_member","guest","service"],"type":"string"},"v2_ConversationAccessData_MjMxMTI5ODc3":{"properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"$ref":"#/components/schemas/AccessRoleLegacy_LTYwOTAxMDI1"},"access_role_v2":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"}},"required":["access"],"type":"object"},"ConversationMessageTimerUpdate_LTcxMjUwNzQ4":{"description":"Contains conversation properties to update","properties":{"message_timer":{"format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"type":"object"},"ConversationCodeInfo_LTc5MzgzNjg3":{"description":"Contains conversation properties to update","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"has_password":{"description":"Whether the conversation has a password","type":"boolean"},"key":{"$ref":"#/components/schemas/ASCII"},"uri":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["key","code","uri","has_password"],"type":"object"},"ConvType_MzM0NTE3ODE5":{"enum":[0,1,2,3],"type":"integer"},"GroupConvTypeLegacy_NTUxMDI2Mzkw":{"enum":["group_conversation","channel"],"type":"string"},"AddPermission_LTE1MzgzNzE3":{"enum":["admins","everyone"],"type":"string"},"CellsState_LTg4MDEwNDA5":{"enum":["disabled","pending","ready"],"type":"string"},"HistoryDuration":{"type":"string"},"HistorySharingConfig_Mjc4MzA1Nzgw":{"properties":{"depth":{"$ref":"#/components/schemas/HistoryDuration"}},"required":["depth"],"type":"object"},"History":{"properties":{"depth":{"$ref":"#/components/schemas/HistoryDuration"}},"required":["depth"],"type":"object"},"Member_OTA5OTgyNzcw":{"description":"The user ID of the requestor if the requestor is a member of the conversation","properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"status":{},"status_ref":{},"status_time":{}},"required":["qualified_id"],"type":"object"},"OtherMember_LTgzNzE2MTk4":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"id":{"$ref":"#/components/schemas/UUID"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"service":{"$ref":"#/components/schemas/ServiceRef_LTgxMjY3NzAz"},"status":{"deprecated":true,"description":"deprecated","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["qualified_id"],"type":"object"},"OwnConvMembers_LTEwMzUzODMy":{"description":"Users of a conversation","properties":{"others":{"description":"All other current users of this conversation","items":{"$ref":"#/components/schemas/OtherMember_LTgzNzE2MTk4"},"type":"array"},"self":{"$ref":"#/components/schemas/Member_OTA5OTgyNzcw"}},"required":["self","others"],"type":"object"},"ProtocolTag_ODg1MTE5NjEw":{"enum":["proteus","mls","mixed"],"type":"string"},"GroupId":{"description":"A base64-encoded MLS group ID","example":"ZXhhbXBsZQo=","type":"string"},"EpochTimestamp":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"},"CipherSuiteTag":{"description":"The cipher suite of the corresponding MLS group","maximum":65535,"minimum":0,"type":"integer"},"v2_OwnConversation_GroupConvTypeLegacy_MjQ0OTcyNjQ3":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"$ref":"#/components/schemas/AccessRoleLegacy_LTYwOTAxMDI1"},"access_role_v2":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/EpochTimestamp"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvTypeLegacy_NTUxMDI2Mzkw"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","members","group_id","epoch","epoch_timestamp","cipher_suite"],"type":"object"},"GroupConvType_LTU4NjU0MTY5":{"enum":["group_conversation","channel","meeting"],"type":"string"},"v2_OwnConversation_GroupConvType_LTU2MzYxNTg0":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"$ref":"#/components/schemas/AccessRoleLegacy_LTYwOTAxMDI1"},"access_role_v2":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/EpochTimestamp"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","members","group_id","epoch","epoch_timestamp","cipher_suite"],"type":"object"},"Connect_ODY3OTE4NTYx":{"properties":{"email":{"type":"string"},"message":{"type":"string"},"name":{"type":"string"},"qualified_recipient":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"recipient":{"$ref":"#/components/schemas/UUID"}},"required":["qualified_recipient"],"type":"object"},"ConversationReset_MzU1Nzc5MjAw":{"properties":{"group_id":{"$ref":"#/components/schemas/GroupId"},"new_group_id":{"$ref":"#/components/schemas/GroupId"}},"required":["group_id"],"type":"object"},"ConversationReceiptModeUpdate_NDE4MzUzNTU3":{"description":"Contains conversation receipt mode to update to. Receipt mode tells clients whether certain types of receipts should be sent in the given conversation or not. How this value is interpreted is up to clients.","properties":{"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["receipt_mode"],"type":"object"},"OtrMessage_LTY4MTYzNzg3":{"description":"Encrypted message of a conversation","properties":{"data":{"description":"Extra (symmetric) data (i.e. ciphertext, Base64 in JSON) that is common with all other recipients.","type":"string"},"recipient":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"sender":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"text":{"description":"The ciphertext for the recipient (Base64 in JSON)","type":"string"}},"required":["sender","recipient","text"],"type":"object"},"TypingStatus_LTg5MzcyNDMy":{"enum":["started","stopped"],"type":"string"},"ProtocolUpdate_NzY1ODgxNDQy":{"properties":{"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"}},"type":"object"},"AddPermissionUpdate_LTU3MzEwOTY4":{"description":"The action of changing the permission to add members to a channel","properties":{"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"}},"required":["add_permission"],"type":"object"},"AdminlessReminder_LTkyMDUxNTk5":{"properties":{"deletion_scheduled_for":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["deletion_scheduled_for"],"type":"object"},"EventVia_Mjc4MzcyNzE0":{"enum":["scim","user"],"type":"string"},"Event_LTMwMTMyODM5":{"properties":{"conversation":{"$ref":"#/components/schemas/UUID"},"data":{"description":"The action of changing the permission to add members to a channel","example":"ZXhhbXBsZQo=","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"$ref":"#/components/schemas/AccessRoleLegacy_LTYwOTAxMDI1"},"access_role_v2":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"add_type":{"$ref":"#/components/schemas/JoinType_LTY4MDg2MzA5"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"code":{"$ref":"#/components/schemas/ASCII"},"conversation_role":{"$ref":"#/components/schemas/RoleName"},"creator":{"$ref":"#/components/schemas/UUID"},"data":{"description":"Extra (symmetric) data (i.e. ciphertext, Base64 in JSON) that is common with all other recipients.","type":"string"},"deletion_scheduled_for":{"$ref":"#/components/schemas/UTCTimeMillis"},"depth":{"$ref":"#/components/schemas/HistoryDuration"},"email":{"type":"string"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/EpochTimestamp"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"has_password":{"description":"Whether the conversation has a password","type":"boolean"},"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"key":{"$ref":"#/components/schemas/ASCII"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message":{"type":"string"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"new_group_id":{"$ref":"#/components/schemas/GroupId"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_recipient":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"qualified_target":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"qualified_user_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"reason":{"$ref":"#/components/schemas/EdMemberLeftReason_OTAyMDA4NzEw"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"recipient":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"sender":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"status":{"$ref":"#/components/schemas/TypingStatus_LTg5MzcyNDMy"},"target":{"$ref":"#/components/schemas/UUID"},"team":{"$ref":"#/components/schemas/UUID"},"text":{"description":"The ciphertext for the recipient (Base64 in JSON)","type":"string"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"},"uri":{"$ref":"#/components/schemas/HttpsUrl"},"user_ids":{"deprecated":true,"description":"Deprecated, use qualified_user_ids","items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"users":{"items":{"$ref":"#/components/schemas/SimpleMember_NTY5MTcxMzcx"},"type":"array"}},"required":["users","add_type","reason","qualified_user_ids","user_ids","qualified_target","name","access","key","code","uri","has_password","qualified_id","type","members","group_id","epoch","epoch_timestamp","cipher_suite","qualified_recipient","receipt_mode","sender","recipient","text","status","add_permission","depth","deletion_scheduled_for"],"type":"object"},"from":{"$ref":"#/components/schemas/UUID"},"qualified_conversation":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_from":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"subconv":{"type":"string"},"team":{"$ref":"#/components/schemas/UUID"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"},"type":{"$ref":"#/components/schemas/EventType_LTQ3NTQyNDYz"},"via":{"$ref":"#/components/schemas/EventVia_Mjc4MzcyNzE0"}},"required":["type","data","qualified_conversation","qualified_from","via","time"],"type":"object"},"AddBot_NjI0ODkyODk3":{"properties":{"locale":{"$ref":"#/components/schemas/Locale"},"provider":{"$ref":"#/components/schemas/UUID"},"service":{"$ref":"#/components/schemas/UUID"}},"required":["provider","service"],"type":"object"},"RemoveBotResponse_LTUxNTQ4MDEy":{"properties":{"event":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"}},"required":["event"],"type":"object"},"UpdateBotPrekeys_LTg3NzYxODg0":{"properties":{"prekeys":{"items":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"type":"array"}},"required":["prekeys"],"type":"object"},"UserClients":{"additionalProperties":{"items":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"type":"array"},"description":"Map of user id to list of client ids.","example":{"1d51e2d6-9c70-605f-efc8-ff85c3dabdc7":["60f85e4b15ad3786","6e323ab31554353b"]},"type":"object"},"BotUserView_LTE2MTkwMTcw":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"handle":{"$ref":"#/components/schemas/Handle"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"team":{"$ref":"#/components/schemas/UUID"}},"required":["id","name","accent_id"],"type":"object"},"NewServiceResponse_LTExMzcwMjg5":{"properties":{"auth_token":{"$ref":"#/components/schemas/ASCII"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["id"],"type":"object"},"NewService_LTYwOTU1MDQ3":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"auth_token":{"$ref":"#/components/schemas/ASCII"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"description":{"maxLength":1024,"minLength":1,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"public_key":{"$ref":"#/components/schemas/ServiceKeyPEM"},"summary":{"maxLength":128,"minLength":1,"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"maxItems":3,"minItems":1,"type":"array"}},"required":["name","summary","description","base_url","public_key","assets","tags"],"type":"object"},"ServiceKeyPEM":{"example":"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu+Kg/PHHU3atXrUbKnw0\nG06FliXcNt3lMwl2os5twEDcPPFw/feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPH\nWvUBdiLfGrZqJO223DB6D8K2Su/odmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKV\nVPOaOzgtAB21XKRiQ4ermqgi3/njr03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiS\nbUKr/BeArYRcjzr/h5m1In6fG/if9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg8\n7X883H+LA/d6X5CTiPv1VMxXdBUiGPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7la\nnQIDAQAB\n-----END PUBLIC KEY-----\n","type":"string"},"ServiceTag_LTMyNTEzNjYy":{"enum":["audio","books","business","design","education","entertainment","finance","fitness","food-drink","games","graphics","health","integration","lifestyle","media","medical","movies","music","news","photography","poll","productivity","quiz","rating","shopping","social","sports","travel","tutorial","video","weather"],"type":"string"},"Service_MjcyOTA5NjQx":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"auth_tokens":{"items":{"$ref":"#/components/schemas/ASCII"},"minItems":1,"type":"array"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"description":{"type":"string"},"enabled":{"type":"boolean"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"public_keys":{"items":{"$ref":"#/components/schemas/ServiceKey_NzY5NTY5NzYy"},"minItems":1,"type":"array"},"summary":{"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"type":"array"}},"required":["id","name","summary","description","base_url","auth_tokens","public_keys","assets","tags","enabled"],"type":"object"},"ServiceKeyType_NTEzNzI4NTA2":{"enum":["rsa"],"type":"string"},"ServiceKey_NzY5NTY5NzYy":{"properties":{"pem":{"$ref":"#/components/schemas/ServiceKeyPEM"},"size":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"type":{"$ref":"#/components/schemas/ServiceKeyType_NTEzNzI4NTA2"}},"required":["type","size","pem"],"type":"object"},"UpdateService_MjAxNzQ2Njkz":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"description":{"maxLength":1024,"minLength":1,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"summary":{"maxLength":128,"minLength":1,"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"maxItems":3,"minItems":1,"type":"array"}},"type":"object"},"UpdateServiceConn_LTQ1OTYwNjIz":{"properties":{"auth_tokens":{"items":{"$ref":"#/components/schemas/ASCII"},"maxItems":2,"minItems":1,"type":"array"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"enabled":{"type":"boolean"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"public_keys":{"items":{"$ref":"#/components/schemas/ServiceKeyPEM"},"maxItems":2,"minItems":1,"type":"array"}},"required":["password"],"type":"object"},"DeleteService_LTY2NzY5NzMz":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["password"],"type":"object"},"ServiceProfile_LTc2MDQzNTk3":{"properties":{"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"description":{"type":"string"},"enabled":{"type":"boolean"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"provider":{"$ref":"#/components/schemas/UUID"},"summary":{"type":"string"},"tags":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"type":"array"}},"required":["id","provider","name","summary","description","assets","tags","enabled"],"type":"object"},"ServiceProfilePage_Njg1NDQ5Njc4":{"properties":{"has_more":{"type":"boolean"},"services":{"items":{"$ref":"#/components/schemas/ServiceProfile_LTc2MDQzNTk3"},"type":"array"}},"required":["has_more","services"],"type":"object"},"ServiceTagList":{"items":{"$ref":"#/components/schemas/ServiceTag_LTMyNTEzNjYy"},"type":"array"},"UpdateServiceWhitelist_LTU5MDAwMTIw":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"provider":{"$ref":"#/components/schemas/UUID"},"whitelisted":{"type":"boolean"}},"required":["provider","id","whitelisted"],"type":"object"},"NewProviderResponse_OTE0ODI2NjU0":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"password":{"maxLength":1024,"minLength":8,"type":"string"}},"required":["id"],"type":"object"},"NewProvider_LTEyMTY5MjYy":{"properties":{"description":{"maxLength":1024,"minLength":1,"type":"string"},"email":{"$ref":"#/components/schemas/Email"},"name":{"maxLength":128,"minLength":1,"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["name","email","url","description"],"type":"object"},"ProviderActivationResponse_LTgzNTU3MzA5":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"ProviderLogin_LTE2MTk2NTM5":{"properties":{"email":{"$ref":"#/components/schemas/Email"},"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["email","password"],"type":"object"},"PasswordReset_LTYzNDYxNTQ3":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"CompletePasswordReset_LTYzMDAxNDA1":{"properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"},"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["key","code","password"],"type":"object"},"DeleteProvider_MzYxMzM3Mjg2":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["password"],"type":"object"},"UpdateProvider_LTQwMjY4MDgy":{"properties":{"description":{"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"url":{"$ref":"#/components/schemas/HttpsUrl"}},"type":"object"},"EmailUpdate_LTYwODE0ODQ5":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"PasswordChange_NDI0ODgwNDU0":{"properties":{"new_password":{"maxLength":1024,"minLength":6,"type":"string"},"old_password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["old_password","new_password"],"type":"object"},"Provider_NDIyMzQ3ODIy":{"properties":{"description":{"type":"string"},"email":{"$ref":"#/components/schemas/Email"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["id","name","email","url","description"],"type":"object"},"DomainRedirectConfig_NTI5NDE5MDQy":{"properties":{"backend":{"$ref":"#/components/schemas/HttpsUrl_HttpsUrl_NjUyMDgzNzk3"},"domain_redirect":{"$ref":"#/components/schemas/DomainRedirectConfigTag_MjE2MDI4MDIw"}},"required":["domain_redirect","backend"],"type":"object"},"DomainRedirectConfigTag_MjE2MDI4MDIw":{"enum":["remove","backend","no-registration"],"type":"string"},"HttpsUrl_HttpsUrl_NjUyMDgzNzk3":{"properties":{"config_url":{"$ref":"#/components/schemas/HttpsUrl"},"webapp_url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["config_url","webapp_url"],"type":"object"},"DomainRedirectResponse_V10_LTEyMjI4NTM0":{"properties":{"backend":{"$ref":"#/components/schemas/HttpsUrl_Maybe_HttpsUrl_LTQ1MDkyMzY2"},"domain_redirect":{"$ref":"#/components/schemas/DomainRedirectTag_LTY3NjU1MDEy"},"due_to_existing_account":{"type":"boolean"},"sso_code":{"$ref":"#/components/schemas/UUID"}},"required":["domain_redirect","sso_code","backend"],"type":"object"},"DomainRedirectTag_LTY3NjU1MDEy":{"enum":["none","locked","sso","backend","no-registration","pre-authorized"],"type":"string"},"HttpsUrl_Maybe_HttpsUrl_LTQ1MDkyMzY2":{"properties":{"config_url":{"$ref":"#/components/schemas/HttpsUrl"},"webapp_url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["config_url"],"type":"object"},"GetDomainRegistrationRequest_LTg4NTM1MzM2":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"DomainOwnershipToken_NTU0ODc1NDE5":{"properties":{"domain_ownership_token":{"$ref":"#/components/schemas/Token"}},"required":["domain_ownership_token"],"type":"object"},"Base64URLByteString":{"example":"ZXhhbXBsZQo=","type":"string"},"Token":{"example":"ZXhhbXBsZQo=","type":"string"},"ChallengeToken_Mzk3NTcwOTM3":{"properties":{"challenge_token":{"$ref":"#/components/schemas/Token"}},"required":["challenge_token"],"type":"object"},"TeamInviteConfig_MTg4Nzk4NzMz":{"properties":{"domain_redirect":{"$ref":"#/components/schemas/TeamDomainRedirectTag_MjQwMjc1Mjk3"},"sso":{"example":"99db9768-04e3-4b5d-9268-831b6a25c4ab","format":"uuid","type":"string"},"team":{"$ref":"#/components/schemas/UUID"},"team_invite":{"$ref":"#/components/schemas/TeamInviteTag_LTQyNTMyNzA0"}},"required":["team_invite","team"],"type":"object"},"TeamInviteTag_LTQyNTMyNzA0":{"enum":["allowed","not-allowed","team"],"type":"string"},"TeamDomainRedirectTag_MjQwMjc1Mjk3":{"enum":["no-registration","none"],"type":"string"},"RegisteredDomains_V10_NDYwNzYyMTMy":{"properties":{"registered_domains":{"items":{"$ref":"#/components/schemas/DomainRegistrationResponse_V10_MjE0NDkxODY4"},"type":"array"}},"required":["registered_domains"],"type":"object"},"DomainRegistrationResponse_V10_MjE0NDkxODY4":{"properties":{"authorized_team":{"$ref":"#/components/schemas/UUID"},"backend":{"$ref":"#/components/schemas/HttpsUrl_Maybe_HttpsUrl_LTQ1MDkyMzY2"},"dns_verification_token":{"$ref":"#/components/schemas/ASCII"},"domain":{"$ref":"#/components/schemas/Domain"},"domain_redirect":{"$ref":"#/components/schemas/DomainRedirectTag_LTY3NjU1MDEy"},"sso_code":{"$ref":"#/components/schemas/UUID"},"team":{"$ref":"#/components/schemas/UUID"},"team_invite":{"$ref":"#/components/schemas/TeamInviteTag_LTQyNTMyNzA0"}},"required":["domain","domain_redirect","sso_code","backend","team_invite","team"],"type":"object"},"DomainVerificationChallenge_NjIwMzA1MjE5":{"properties":{"dns_verification_token":{"$ref":"#/components/schemas/ASCII"},"id":{"$ref":"#/components/schemas/UUID"},"token":{"$ref":"#/components/schemas/Token"}},"required":["id","token","dns_verification_token"],"type":"object"},"UserGroup_Identity_NTg4MTY1MjEx":{"properties":{"channels":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"},"channelsCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"createdAt":{"$ref":"#/components/schemas/UTCTimeMillis"},"id":{"$ref":"#/components/schemas/UUID"},"managedBy":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"membersCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["id","name","members","managedBy","createdAt"],"type":"object"},"NewUserGroup_MzYxODU0OTU1":{"properties":{"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["name","members"],"type":"object"},"UserGroupPage_UserGroup_Const_LTMxNDg5MDAy":{"description":"This is the last page if it contains fewer rows than requested. There may be 0 rows on a page.","properties":{"page":{"items":{"$ref":"#/components/schemas/UserGroup_Const_NTMzOTAzMzA1"},"type":"array"},"total":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["page","total"],"type":"object"},"UserGroup_Const_NTMzOTAzMzA1":{"properties":{"channels":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"},"channelsCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"createdAt":{"$ref":"#/components/schemas/UTCTimeMillis"},"id":{"$ref":"#/components/schemas/UUID"},"managedBy":{"$ref":"#/components/schemas/ManagedBy_NTI0ODc0NTQx"},"membersCount":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["id","name","managedBy","createdAt"],"type":"object"},"UserGroupUpdate_MjUyNTA3Mjgy":{"properties":{"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["name"],"type":"object"},"UserGroupAddUsers_LTgzOTYzNzk0":{"properties":{"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["members"],"type":"object"},"UpdateUserGroupMembers_LTg1MzQ2NDY3":{"properties":{"members":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["members"],"type":"object"},"UpdateUserGroupChannels_LTIyMjcwMTMx":{"properties":{"channels":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["channels"],"type":"object"},"UserGroupNameAvailability_LTYzMDE1NTk4":{"properties":{"name_available":{"type":"boolean"}},"required":["name_available"],"type":"object"},"CheckUserGroupName_LTg0ODU1OTk1":{"properties":{"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["name"],"type":"object"},"CreatedApp_LTM3NjUxOTY1":{"properties":{"cookie":{"$ref":"#/components/schemas/SomeUserToken"},"user":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}},"required":["user","cookie"],"type":"object"},"SomeUserToken":{"type":"string"},"NewApp_LTQwODMwMzQ4":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"category":{"description":"Category name (if uncertain, pick \"other\")","type":"string"},"description":{"maxLength":300,"minLength":0,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"}},"required":["name","category","description","password"],"type":"object"},"PutApp_LTE4MDc1OTM4":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"category":{"description":"Category name (if uncertain, pick \"other\")","type":"string"},"description":{"maxLength":300,"minLength":0,"type":"string"},"name":{"maxLength":128,"minLength":1,"type":"string"}},"type":"object"},"RefreshAppCookieResponse_LTQ0MjU1NTIw":{"properties":{"cookie":{"$ref":"#/components/schemas/SomeUserToken"}},"required":["cookie"],"type":"object"},"RefreshAppCookieRequest_MjEyMDMyMTk5":{"properties":{"password":{"description":"The password of the authenticated admin for verification. or if the user has only SAML credentials.","maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"Conversation_GroupConvType_MzQzMTQ1OTg3":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/ConvMembers_LTc2MDg1NDg2"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"},"ConvMembers_LTc2MDg1NDg2":{"description":"Users of a conversation","properties":{"others":{"description":"All other current users of this conversation","items":{"$ref":"#/components/schemas/OtherMember_LTgzNzE2MTk4"},"type":"array"},"self":{"$ref":"#/components/schemas/Member_OTA5OTgyNzcw"}},"required":["others"],"type":"object"},"ConversationRolesList":{"properties":{"conversation_roles":{"items":{"$ref":"#/components/schemas/ConversationRole"},"type":"array"}},"required":["conversation_roles"],"type":"object"},"ConversationRole":{"properties":{"actions":{"description":"The set of actions allowed for this role","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"conversation_role":{"$ref":"#/components/schemas/RoleName"}}},"Action":{"enum":["add_conversation_member","remove_conversation_member","modify_conversation_name","modify_conversation_message_timer","modify_conversation_receipt_mode","modify_conversation_access","modify_other_conversation_member","leave_conversation","delete_conversation","modify_add_permission"],"type":"string"},"GroupInfoData":{"description":"This object can only be parsed in TLS format. Please refer to the MLS specification for details."},"MultiTabePg_ConvrsIdqf_cLORm_Q_Mzg0MjYzNzM0":{"properties":{"has_more":{"type":"boolean"},"paging_state":{"$ref":"#/components/schemas/ConversationIds_PagingState"},"qualified_conversations":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"}},"required":["qualified_conversations","has_more","paging_state"],"type":"object"},"ConversationIds_PagingState":{"type":"string"},"GetMuliTabPgRqs_ConvrIdLcOm10_ODU0NjQ1NDMz":{"description":"A request to list some or all of a user's ConversationIds, including remote ones","properties":{"paging_state":{"$ref":"#/components/schemas/ConversationIds_PagingState"},"size":{"description":"optional, must be <= 1000, defaults to 1000.","format":"int32","maximum":1000,"minimum":1,"type":"integer"}},"type":"object"},"ConversationsResponse_GroupConvType_ODkxMjM2ODM0":{"description":"Response object for getting metadata of a list of conversations","properties":{"failed":{"description":"The server failed to fetch these conversations, most likely due to network issues while contacting a remote server","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"},"found":{"items":{"$ref":"#/components/schemas/OwnConversation_GroupConvType_LTU2MzYxNTg0"},"type":"array"},"not_found":{"description":"These conversations either don't exist or are deleted.","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"type":"array"}},"required":["found","not_found","failed"],"type":"object"},"OwnConversation_GroupConvType_LTU2MzYxNTg0":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"},"ListConversations_MjkxMTIwODMz":{"description":"A request to list some of a user's conversations, including remote ones. Maximum 1000 qualified conversation IDs","properties":{"qualified_ids":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"maxItems":1000,"minItems":1,"type":"array"}},"required":["qualified_ids"],"type":"object"},"ConversationCoverView_LTMwNDkxMTA1":{"description":"Limited view of Conversation.","properties":{"has_password":{"type":"boolean"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"}},"required":["id","has_password"],"type":"object"},"CreateGroupConversation_GroupConvType_MTQ3OTc4NDk3":{"description":"A created group-conversation object extended with a list of failed-to-add users","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"failed_to_add":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/ConvMembers_LTc2MDg1NDg2"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch","failed_to_add"],"type":"object"},"NewConv_LTgzNTk1NDQx":{"description":"JSON object to create a new conversation. When using 'qualified_users' (preferred), you can omit 'users'","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells":{"type":"boolean"},"conversation_role":{"$ref":"#/components/schemas/RoleName"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"history":{"$ref":"#/components/schemas/History"},"message_timer":{"description":"Per-conversation message timer","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"maxLength":256,"minLength":1,"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"qualified_users":{"description":"List of qualified user IDs (excluding the requestor) to be part of this conversation","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"skip_creator":{"description":"Don't add creator to the conversation, only works for team admins not wanting to be part of the channels they create.","type":"boolean"},"team":{"$ref":"#/components/schemas/ConvTeamInfo_Mzc5NjcyNjAz"},"users":{"deprecated":true,"description":"List of user IDs (excluding the requestor) to be part of this conversation (deprecated)","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"type":"object"},"ConvTeamInfo_Mzc5NjcyNjAz":{"description":"Team information of this conversation","properties":{"managed":{"description":"This field MUST NOT be used by clients. It is here only for backwards compatibility of the interface."},"teamid":{"$ref":"#/components/schemas/UUID"}},"required":["teamid","managed"],"type":"object"},"v6_OwnConversation_GroupConvType_LTU2MzYxNTg0V6":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"},"v9_OwnConversation_GroupConvType_LTU2MzYxNTg0":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch"],"type":"object"},"PublicSubConversation_MjI2NTIxMzU4":{"description":"An MLS subconversation","properties":{"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/UTCTime"},"group_id":{"$ref":"#/components/schemas/GroupId"},"members":{"items":{"$ref":"#/components/schemas/ClientIdentity_MjAxMjI3NTUw"},"type":"array"},"parent_qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"subconv_id":{"type":"string"}},"required":["parent_qualified_id","subconv_id","group_id","epoch","members"],"type":"object"},"ClientIdentity_MjAxMjI3NTUw":{"properties":{"client_id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"domain":{"$ref":"#/components/schemas/Domain"},"user_id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","user_id","client_id"],"type":"object"},"MLSReset_NzgwODA3ODc4":{"properties":{"epoch":{"format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"group_id":{"$ref":"#/components/schemas/GroupId"}},"required":["group_id","epoch"],"type":"object"},"v3_OwnConversation_GroupConvType_LTU2MzYxNTg0V3":{"description":"A conversation object as returned from the server","properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"},"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"},"cells_state":{"$ref":"#/components/schemas/CellsState_LTg4MDEwNDA5"},"cipher_suite":{"$ref":"#/components/schemas/CipherSuiteTag"},"creator":{"$ref":"#/components/schemas/UUID"},"epoch":{"description":"The epoch number of the corresponding MLS group","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"epoch_timestamp":{"$ref":"#/components/schemas/EpochTimestamp"},"group_conv_type":{"$ref":"#/components/schemas/GroupConvType_LTU4NjU0MTY5"},"group_id":{"$ref":"#/components/schemas/GroupId"},"history":{"$ref":"#/components/schemas/History"},"id":{"$ref":"#/components/schemas/UUID"},"last_event":{"type":"string"},"last_event_time":{"type":"string"},"members":{"$ref":"#/components/schemas/OwnConvMembers_LTEwMzUzODMy"},"message_timer":{"description":"Per-conversation message timer (can be null)","format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"},"parent":{"$ref":"#/components/schemas/UUID"},"protocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"receipt_mode":{"description":"Conversation receipt mode","format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"team":{"$ref":"#/components/schemas/UUID"},"type":{"$ref":"#/components/schemas/ConvType_MzM0NTE3ODE5"}},"required":["qualified_id","type","access","access_role","members","group_id","epoch","epoch_timestamp","cipher_suite"],"type":"object"},"NewOne2OneConv_LTI3OTc4NDAz":{"description":"JSON object to create a new 1:1 conversation. When using 'qualified_users' (preferred), you can omit 'users'","properties":{"name":{"maxLength":256,"minLength":1,"type":"string"},"qualified_users":{"description":"List of qualified user IDs (excluding the requestor) to be part of this conversation","items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"type":"array"},"team":{"$ref":"#/components/schemas/ConvTeamInfo_Mzc5NjcyNjAz"},"users":{"deprecated":true,"description":"List of user IDs (excluding the requestor) to be part of this conversation (deprecated)","items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"type":"object"},"MLSOne2OneConversation_SomeKey_GroupConvType_LTg2MTcwMjY3":{"properties":{"conversation":{"$ref":"#/components/schemas/v9_OwnConversation_GroupConvType_LTU2MzYxNTg0"},"public_keys":{"$ref":"#/components/schemas/MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx"}},"required":["conversation","public_keys"],"type":"object"},"SomeKey":{},"MLSKeys_SomeKey_LTUzNDA5MzA3":{"properties":{"ecdsa_secp256r1_sha256":{"$ref":"#/components/schemas/SomeKey"},"ecdsa_secp384r1_sha384":{"$ref":"#/components/schemas/SomeKey"},"ecdsa_secp521r1_sha512":{"$ref":"#/components/schemas/SomeKey"},"ed25519":{"$ref":"#/components/schemas/SomeKey"}},"required":["ed25519","ecdsa_secp256r1_sha256","ecdsa_secp384r1_sha384","ecdsa_secp521r1_sha512"],"type":"object"},"MLSKeysByPurpose_MLSKeys_SomeKey_NjkxMjk0MTAx":{"properties":{"removal":{"$ref":"#/components/schemas/MLSKeys_SomeKey_LTUzNDA5MzA3"}},"required":["removal"],"type":"object"},"InviteQualified_ODYyODIyNjYz":{"properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"},"qualified_users":{"items":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"minItems":1,"type":"array"}},"required":["qualified_users"],"type":"object"},"JoinConversationByCode_NjgzMzM4Mjg5":{"description":"Request body for joining a conversation by code","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"},"password":{"maxLength":1024,"minLength":8,"type":"string"}},"required":["key","code"],"type":"object"},"ConversationCode_Mjg3OTI1NTMx":{"description":"Contains conversation properties to update","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"key":{"$ref":"#/components/schemas/ASCII"}},"required":["key","code"],"type":"object"},"CreateConversationCodeRequest_NTYzMTA1NDYz":{"description":"Request body for creating a conversation code","properties":{"password":{"description":"Password for accessing the conversation via guest link. Set to null or omit for no password.","maxLength":1024,"minLength":8,"type":"string"}},"type":"object"},"LockableFeature_GuestLinksConfig_LTcwNjU0NDMw":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"FeatureStatus_LTMzMTUwODEw":{"enum":["enabled","disabled"],"type":"string"},"LockStatus_LTIyMTU5OTkw":{"enum":["locked","unlocked"],"type":"string"},"OtherMemberUpdate_LTM1MjYzOTU0":{"description":"Update user properties of other members relative to a conversation","properties":{"conversation_role":{"$ref":"#/components/schemas/RoleName"}},"type":"object"},"ConversationAccessData_MjMxMTI5ODc3":{"properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"access_role":{"items":{"$ref":"#/components/schemas/AccessRole_Mzk3MDYzMzcw"},"type":"array"}},"required":["access","access_role"],"type":"object"},"ConversationHistoryUpdate_LTg5MDQ5Nzgx":{"properties":{"history":{"$ref":"#/components/schemas/History"}},"required":["history"],"type":"object"},"MemberUpdate_LTg4NTQ0OTYz":{"properties":{"hidden":{"type":"boolean"},"hidden_ref":{"type":"string"},"otr_archived":{"type":"boolean"},"otr_archived_ref":{"type":"string"},"otr_muted_ref":{"type":"string"},"otr_muted_status":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"type":"object"},"TeamConversationList_OTI3MzY3NzY0":{"description":"Team conversation list","properties":{"conversations":{"items":{"$ref":"#/components/schemas/TeamConversation_LTIwNzgyNTEz"},"type":"array"}},"required":["conversations"],"type":"object"},"TeamConversation_LTIwNzgyNTEz":{"description":"Team conversation data","properties":{"conversation":{"$ref":"#/components/schemas/UUID"},"managed":{"description":"This field MUST NOT be used by clients. It is here only for backwards compatibility of the interface."}},"required":["conversation","managed"],"type":"object"},"ClientMismatch_ODUyODM0MDQ0":{"properties":{"deleted":{"$ref":"#/components/schemas/UserClients"},"missing":{"$ref":"#/components/schemas/UserClients"},"redundant":{"$ref":"#/components/schemas/UserClients"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["time","missing","redundant","deleted"],"type":"object"},"NewOtrMessage_LTUyMTE5MTMw":{"properties":{"data":{"type":"string"},"native_priority":{"$ref":"#/components/schemas/Priority_ODA3NDM3MDYy"},"native_push":{"type":"boolean"},"recipients":{"$ref":"#/components/schemas/UserClientMap"},"report_missing":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"sender":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"transient":{"type":"boolean"}},"required":["sender","recipients"],"type":"object"},"UserClientMap":{"additionalProperties":{"additionalProperties":{"type":"string"},"type":"object"},"type":"object"},"Priority_ODA3NDM3MDYy":{"enum":["low","high"],"type":"string"},"MessageSendingStatus_ODg0NDgyNDk4":{"description":"The Proteus message sending status. It has these fields:\n- `time`: Time of sending message.\n- `missing`: Clients that the message /should/ have been encrypted for, but wasn't.\n- `redundant`: Clients that the message /should not/ have been encrypted for, but was.\n- `deleted`: Clients that were deleted.\n- `failed_to_send`: When message sending fails for some clients but succeeds for others, e.g., because a remote backend is unreachable, this field will contain the list of clients for which the message sending failed. This list should be empty when message sending is not even tried, like when some clients are missing.","properties":{"deleted":{"$ref":"#/components/schemas/QualifiedUserClients"},"failed_to_confirm_clients":{"$ref":"#/components/schemas/QualifiedUserClients"},"failed_to_send":{"$ref":"#/components/schemas/QualifiedUserClients"},"missing":{"$ref":"#/components/schemas/QualifiedUserClients"},"redundant":{"$ref":"#/components/schemas/QualifiedUserClients"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["time","missing","redundant","deleted","failed_to_send","failed_to_confirm_clients"],"type":"object"},"QualifiedNewOtrMessage":{"description":"This object can only be parsed from Protobuf.\nThe specification for the protobuf types is here: \nhttps://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto."},"BotConvView_LTYzMjIzMjQz":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"members":{"items":{"$ref":"#/components/schemas/OtherMember_LTgzNzE2MTk4"},"type":"array"},"name":{"type":"string"}},"required":["id","members"],"type":"object"},"TeamUpdateData_LTE0NTM2NTU5":{"properties":{"icon":{"$ref":"#/components/schemas/Icon"},"icon_key":{"maxLength":256,"minLength":1,"type":"string"},"name":{"maxLength":256,"minLength":1,"type":"string"},"splash_screen":{"$ref":"#/components/schemas/Icon"}},"type":"object"},"Team_NDg4MjQwOTIw":{"description":"`binding` is deprecated, and should be ignored. The non-binding teams API is not used (and will not be supported from API version V4 onwards), and `binding` will always be `true`.","properties":{"binding":{"$ref":"#/components/schemas/TeamBinding_LTE4NTM5MTc0"},"creator":{"$ref":"#/components/schemas/UUID"},"icon":{"$ref":"#/components/schemas/Icon"},"icon_key":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"splash_screen":{"$ref":"#/components/schemas/Icon"}},"required":["id","creator","name","icon"],"type":"object"},"TeamBinding_LTE4NTM5MTc0":{"deprecated":true,"description":"Deprecated, please ignore.","enum":[true,false],"type":"boolean"},"TeamDeleteData_ODI5NTU0ODE5":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"type":"object"},"ConversationPage_LTIwMDU2NDI3":{"description":"This is the last page if it contains fewer rows than requested. There may be 0 rows on a page.","properties":{"page":{"items":{"$ref":"#/components/schemas/ConversationSearchResult_NDI0MTcyMDU3"},"type":"array"}},"required":["page"],"type":"object"},"ConversationSearchResult_NDI0MTcyMDU3":{"properties":{"access":{"items":{"$ref":"#/components/schemas/Access_NjkyMzE5ODc0"},"type":"array"},"admin_count":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"id":{"$ref":"#/components/schemas/UUID"},"member_count":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"name":{"type":"string"}},"required":["id","access","member_count","admin_count"],"type":"object"},"LockableFeature_SSOConfig_NjcyMjU4MDY2":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_LegalholdConfig_LTc5MTk5OTIw":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_LegalholdConfig_NjM3MTkxNjYw":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_SearchVisibilityAvailableConfig_LTMzNTkxODI1":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"TeamSearchVisibilityView_Mzg3MzMzMTk3":{"description":"Search visibility value for the team","properties":{"search_visibility":{"$ref":"#/components/schemas/TeamSearchVisibility_LTIzODE2Njk3"}},"required":["search_visibility"],"type":"object"},"TeamSearchVisibility_LTIzODE2Njk3":{"description":"value of visibility","enum":["standard","no-name-outside-team"],"type":"string"},"LockableFtur_RqiExnmVfCg_NjUyMzgzNzY5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_DigitalSignaturesConfig_ODM2MDA2NDU4":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5":{"properties":{"config":{"$ref":"#/components/schemas/AppLockConfigB_Covered_Identity_NDIxOTc2Njkz"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"AppLockConfigB_Covered_Identity_NDIxOTc2Njkz":{"properties":{"enforceAppLock":{"type":"boolean"},"inactivityTimeoutSecs":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["enforceAppLock","inactivityTimeoutSecs"],"type":"object"},"Feature_AppLockConfigB_Bare_Identity_MTAzMjI5NDYy":{"properties":{"config":{"$ref":"#/components/schemas/AppLockConfigB_Covered_Identity_NDIxOTc2Njkz"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"LockableFeature_FileSharingConfig_MjgwNjIzODEz":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_FileSharingConfig_LTUyNjkxMzM4":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFeature_ClassifiedDomainsConfig_LTY1ODQwODg1":{"properties":{"config":{"$ref":"#/components/schemas/ClassifiedDomainsConfig_LTg4MDcwMDg2"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"ClassifiedDomainsConfig_LTg4MDcwMDg2":{"properties":{"domains":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["domains"],"type":"object"},"LockableFtur_CnfigBIdy_NzY1NDU5MDAy":{"properties":{"config":{"$ref":"#/components/schemas/ConferenceCallingConfigB_Covered_Identity_NDMwMjcyMzM1"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"ConferenceCallingConfigB_Covered_Identity_NDMwMjcyMzM1":{"properties":{"useSFTForOneToOneCalls":{"type":"boolean"}},"type":"object"},"Feature_ConferenceCallingConfigB_Bare_Identity_Njc2NTcxNTI3":{"properties":{"config":{"$ref":"#/components/schemas/ConferenceCallingConfigB_Covered_Identity_NDMwMjcyMzM1"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2":{"properties":{"config":{"$ref":"#/components/schemas/SelfDeletingMessagesConfigB_Covered_Identity_LTY0NzQ4MzU1"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"SelfDeletingMessagesConfigB_Covered_Identity_LTY0NzQ4MzU1":{"properties":{"enforcedTimeoutSeconds":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["enforcedTimeoutSeconds"],"type":"object"},"Featur_SlfDingMsCoBIdy_LTg2MzYzNjc2":{"properties":{"config":{"$ref":"#/components/schemas/SelfDeletingMessagesConfigB_Covered_Identity_LTY0NzQ4MzU1"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_GuestLinksConfig_NjQyMDMxNjg3":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_SndFactorPasswordChallengeConfig_NDc0MzUyMzQz":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw":{"properties":{"config":{"$ref":"#/components/schemas/MLSConfigB_Covered_Identity_LTEzNTk3MzM5"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"MLSConfigB_Covered_Identity_LTEzNTk3MzM5":{"description":"allowlist of users that may change protocols","properties":{"allowedCipherSuites":{"items":{"$ref":"#/components/schemas/CipherSuiteTag"},"type":"array"},"defaultCipherSuite":{"$ref":"#/components/schemas/CipherSuiteTag"},"defaultProtocol":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"groupInfoDiagnostics":{"type":"boolean"},"protocolToggleUsers":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"},"supportedProtocols":{"items":{"$ref":"#/components/schemas/ProtocolTag_ODg1MTE5NjEw"},"type":"array"}},"required":["protocolToggleUsers","defaultProtocol","allowedCipherSuites","defaultCipherSuite","supportedProtocols"],"type":"object"},"Feature_MLSConfigB_Bare_Identity_LTI5MjA3MDYy":{"properties":{"config":{"$ref":"#/components/schemas/MLSConfigB_Covered_Identity_LTEzNTk3MzM5"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_ExposeInvitationURLsToTeamAdminConfig_LTY5NzY2Mzg5":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_SearchVisibilityInboundConfig_MTI1NzQxODY2":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_OutlookCalIntegrationConfig_LTg0MjIxNTMx":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4":{"properties":{"config":{"$ref":"#/components/schemas/MlsE2EIdConfigB_Covered_Identity_NDQ0NjEwNzA3"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"MlsE2EIdConfigB_Covered_Identity_NDQ0NjEwNzA3":{"description":"When a client first tries to fetch or renew a certificate, they may need to login to an identity provider (IdP) depending on their IdP domain authentication policy. The user may have a grace period during which they can \"snooze\" this login. The duration of this grace period (in seconds) is set in the `verificationDuration` parameter, which is enforced separately by each client. After the grace period has expired, the client will not allow the user to use the application until they have logged to refresh the certificate. The default value is 1 day (86400s). The client enrolls using the Automatic Certificate Management Environment (ACME) protocol. The `acmeDiscoveryUrl` parameter must be set to the HTTPS URL of the ACME server discovery endpoint for this team. It is of the form \"https://acme.{backendDomain}/acme/{provisionerName}/discovery\". For example: `https://acme.example.com/acme/provisioner1/discovery`.","properties":{"acmeDiscoveryUrl":{"$ref":"#/components/schemas/HttpsUrl"},"crlProxy":{"$ref":"#/components/schemas/HttpsUrl"},"useProxyOnMobile":{"type":"boolean"},"verificationExpiration":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"}},"required":["verificationExpiration"],"type":"object"},"Feature_MlsE2EIdConfigB_Bare_Identity_LTUxODYzODEx":{"properties":{"config":{"$ref":"#/components/schemas/MlsE2EIdConfigB_Covered_Identity_NDQ0NjEwNzA3"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"LockableFtur_MsignCfBIdy_LTE1NjAxNjU2":{"properties":{"config":{"$ref":"#/components/schemas/MlsMigrationConfigB_Covered_Identity_LTM3NDQ4Mzg4"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"MlsMigrationConfigB_Covered_Identity_LTM3NDQ4Mzg4":{"properties":{"allowManualMigration":{"type":"boolean"},"finaliseRegardlessAfter":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"},"startTime":{"example":"2021-05-12T10:52:02Z","format":"yyyy-mm-ddThh:MM:ssZ","type":"string"}},"type":"object"},"Feature_MlsMigrationConfigB_Bare_Identity_LTQyMDAxMTkz":{"properties":{"config":{"$ref":"#/components/schemas/MlsMigrationConfigB_Covered_Identity_LTM3NDQ4Mzg4"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4":{"properties":{"config":{"$ref":"#/components/schemas/EnforceFilDwadLtCgB_vIy_LTQ2NjU1Nzcx"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"EnforceFilDwadLtCgB_vIy_LTQ2NjU1Nzcx":{"properties":{"enforcedDownloadLocation":{"type":"string"}},"type":"object"},"Featur_EnfocilDwdLCgBIy_MjcxMzc1NDA5":{"properties":{"config":{"$ref":"#/components/schemas/EnforceFilDwadLtCgB_vIy_LTQ2NjU1Nzcx"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"LockableFeature_LimitedEventFanoutConfig_MTg3ODM0NzU0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"NP_LockableFtur_ghdCnfiSOVsyAvIRqExmDpB_MGwUT2Q_LTM1Mzc0ODgy":{"properties":{"allowedGlobalOperations":{"$ref":"#/components/schemas/LockableFeature_AllowedGlobalOperationsConfig_NjQ1MjA5MDYw"},"appLock":{"$ref":"#/components/schemas/LockableFeature_AppLockConfigB_Bare_Identity_ODgzNDI0OTU5"},"apps":{"$ref":"#/components/schemas/LockableFeature_AppsConfig_MzQyNTMxNTk5"},"assetAuditLog":{"$ref":"#/components/schemas/LockableFeature_AssetAuditLogConfig_NDQ3MzcyMzk2"},"backgroundEffects":{"$ref":"#/components/schemas/LockableFeature_BackgroundEffectsConfig_MTg1MTk3NTM5"},"cells":{"$ref":"#/components/schemas/LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3"},"cellsInternal":{"$ref":"#/components/schemas/LockableFtur_CsInfigBdy_LTQ3MTU3Mjg0"},"channels":{"$ref":"#/components/schemas/LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw"},"chatBubbles":{"$ref":"#/components/schemas/LockableFeature_ChatBubblesConfig_NDgwMTQzNjI2"},"classifiedDomains":{"$ref":"#/components/schemas/LockableFeature_ClassifiedDomainsConfig_LTY1ODQwODg1"},"conferenceCalling":{"$ref":"#/components/schemas/LockableFtur_CnfigBIdy_NzY1NDU5MDAy"},"consumableNotifications":{"$ref":"#/components/schemas/LockableFeature_ConsumableNotificationsConfig_MjUxMjczMjM0"},"conversationGuestLinks":{"$ref":"#/components/schemas/LockableFeature_GuestLinksConfig_LTcwNjU0NDMw"},"digitalSignatures":{"$ref":"#/components/schemas/LockableFeature_DigitalSignaturesConfig_ODM2MDA2NDU4"},"domainRegistration":{"$ref":"#/components/schemas/LockableFeature_DomainRegistrationConfig_NzU0NjczNTE0"},"enforceFileDownloadLocation":{"$ref":"#/components/schemas/LockableFtur_EnfiDwdCgBIy_ODA5OTA5MTQ4"},"exposeInvitationURLsToTeamAdmin":{"$ref":"#/components/schemas/LockableFtur_ExpsInviURTmAdCfg_LTQzMzU2OTY1"},"fileSharing":{"$ref":"#/components/schemas/LockableFeature_FileSharingConfig_MjgwNjIzODEz"},"legalhold":{"$ref":"#/components/schemas/LockableFeature_LegalholdConfig_LTc5MTk5OTIw"},"limitedEventFanout":{"$ref":"#/components/schemas/LockableFeature_LimitedEventFanoutConfig_MTg3ODM0NzU0"},"meetings":{"$ref":"#/components/schemas/LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw"},"meetingsPremium":{"$ref":"#/components/schemas/LockableFeature_MeetingsPremiumConfig_NDg1ODEyNTA1"},"mls":{"$ref":"#/components/schemas/LockableFeature_MLSConfigB_Bare_Identity_MTg1MTc0NTEw"},"mlsE2EId":{"$ref":"#/components/schemas/LockableFeature_MlsE2EIdConfigB_Bare_Identity_MTU2ODkyMDc4"},"mlsMigration":{"$ref":"#/components/schemas/LockableFtur_MsignCfBIdy_LTE1NjAxNjU2"},"outlookCalIntegration":{"$ref":"#/components/schemas/LockableFeature_OutlookCalIntegrationConfig_NjQ0MzMyMzY0"},"preventAdminlessGroups":{"$ref":"#/components/schemas/LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw"},"searchVisibility":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityAvailableConfig_LTkxMTA5ODk5"},"searchVisibilityInbound":{"$ref":"#/components/schemas/LockableFeature_SearchVisibilityInboundConfig_NzA5NzczNTgy"},"selfDeletingMessages":{"$ref":"#/components/schemas/LockableFtur_SfDingMsCBIdy_LTg5MTEwNTA2"},"simplifiedUserConnectionRequestQRCode":{"$ref":"#/components/schemas/LockableFtur_SimpfdUsCnRqQg_Njk5NjU4OTgy"},"sndFactorPasswordChallenge":{"$ref":"#/components/schemas/LockableFeature_SndFactorPasswordChallengeConfig_MjQ3NzQ2ODgx"},"sso":{"$ref":"#/components/schemas/LockableFeature_SSOConfig_NjcyMjU4MDY2"},"stealthUsers":{"$ref":"#/components/schemas/LockableFeature_StealthUsersConfig_LTE1MTk2NzIz"},"validateSAMLemails":{"$ref":"#/components/schemas/LockableFtur_RqiExnmVfCg_NjUyMzgzNzY5"}},"required":["legalhold","sso","searchVisibility","searchVisibilityInbound","validateSAMLemails","digitalSignatures","appLock","fileSharing","classifiedDomains","conferenceCalling","selfDeletingMessages","conversationGuestLinks","sndFactorPasswordChallenge","mls","exposeInvitationURLsToTeamAdmin","outlookCalIntegration","mlsE2EId","mlsMigration","enforceFileDownloadLocation","limitedEventFanout","domainRegistration","channels","preventAdminlessGroups","cells","allowedGlobalOperations","consumableNotifications","chatBubbles","apps","simplifiedUserConnectionRequestQRCode","assetAuditLog","stealthUsers","cellsInternal","meetings","meetingsPremium","backgroundEffects"],"type":"object"},"LockableFeature_DomainRegistrationConfig_NzU0NjczNTE0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"ChannelPermissions_Mzc1MTM3NTg2":{"enum":["team-members","everyone","admins"],"type":"string"},"ChannelsConfigB_Covered_Identity_ODk2MTk3NDQ4":{"properties":{"allowed_to_create_channels":{"$ref":"#/components/schemas/ChannelPermissions_Mzc1MTM3NTg2"},"allowed_to_open_channels":{"$ref":"#/components/schemas/ChannelPermissions_Mzc1MTM3NTg2"}},"required":["allowed_to_create_channels","allowed_to_open_channels"],"type":"object"},"LockableFeature_ChannelsConfigB_Bare_Identity_NzA2NDEyMDEw":{"properties":{"config":{"$ref":"#/components/schemas/ChannelsConfigB_Covered_Identity_ODk2MTk3NDQ4"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"PreventAdminlessGroupsPromotionStrategy_Mjc1ODA2MDU1":{"enum":["alphabetical","random","all"],"type":"string"},"PreventAdminlessGroupsConfigB_Covered_Identity_MjQ2NDYwMTk2":{"properties":{"deletionTimeout":{"maximum":18446744073709551615,"minimum":0,"type":"integer"},"deletionTimeoutDuration":{"type":"string"},"promotionStrategy":{"$ref":"#/components/schemas/PreventAdminlessGroupsPromotionStrategy_Mjc1ODA2MDU1"},"reminderTimeoutDurations":{"items":{"type":"string"},"type":"array"},"reminderTimeouts":{"items":{"maximum":18446744073709551615,"minimum":0,"type":"integer"},"type":"array"}},"required":["promotionStrategy"],"type":"object"},"LockableFtur_PvnAdmisGpCfgBIy_MTQxOTU4Mzgw":{"properties":{"config":{"$ref":"#/components/schemas/PreventAdminlessGroupsConfigB_Covered_Identity_MjQ2NDYwMTk2"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"CellsPropertyStatus_MTQ5NjE2MzQ4":{"enum":["enabled","disabled","enforced"],"type":"string"},"CellsProperty_NzcxMDIzMzk0":{"properties":{"default":{"$ref":"#/components/schemas/CellsPropertyStatus_MTQ5NjE2MzQ4"},"enabled":{"type":"boolean"}},"required":["enabled","default"],"type":"object"},"CellsUsers_LTQ4NTEyODA1":{"properties":{"externals":{"type":"boolean"},"guests":{"type":"boolean"}},"required":["externals","guests"],"type":"object"},"CellsCollaboraStatus_MTgzNTQyNzUz":{"properties":{"enabled":{"type":"boolean"}},"required":["enabled"],"type":"object"},"CellsPublicLinks_MjgxMzQ3Mzk4":{"properties":{"enableFiles":{"type":"boolean"},"enableFolders":{"type":"boolean"},"enforceExpirationDefault":{"format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"enforceExpirationMax":{"format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"enforcePassword":{"type":"boolean"}},"required":["enableFiles","enableFolders","enforcePassword","enforceExpirationMax","enforceExpirationDefault"],"type":"object"},"CellsRecycle_LTQxMTg3NTkx":{"properties":{"allowSkip":{"type":"boolean"},"autoPurgeDays":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"disable":{"type":"boolean"}},"required":["autoPurgeDays","disable","allowSkip"],"type":"object"},"CellsConfigStorage_LTM0NDMwODM4":{"properties":{"perFileQuotaBytes":{"type":"string"},"recycle":{"$ref":"#/components/schemas/CellsRecycle_LTQxMTg3NTkx"}},"required":["perFileQuotaBytes","recycle"],"type":"object"},"CellsUserMetaTags_LTc4Njk4NTY0":{"properties":{"allowFreeValues":{"type":"boolean"},"defaultValues":{"items":{"type":"string"},"type":"array"}},"required":["defaultValues","allowFreeValues"],"type":"object"},"CellsNamespaces_MzUxMjEzOTQw":{"properties":{"usermetaTags":{"$ref":"#/components/schemas/CellsUserMetaTags_LTc4Njk4NTY0"}},"required":["usermetaTags"],"type":"object"},"CellsMetadata_LTY1OTM5MTM0":{"properties":{"namespaces":{"$ref":"#/components/schemas/CellsNamespaces_MzUxMjEzOTQw"}},"required":["namespaces"],"type":"object"},"CellsConfigB_Covered_Identity_LTE1NzkwOTcz":{"example":{"channels":{"default":"enabled","enabled":true},"collabora":{"enabled":false},"groups":{"default":"enabled","enabled":true},"metadata":{"namespaces":{"usermetaTags":{"allowFreeValues":true,"defaultValues":[]}}},"one2one":{"default":"enabled","enabled":true},"publicLinks":{"enableFiles":true,"enableFolders":true,"enforceExpirationDefault":0,"enforceExpirationMax":0,"enforcePassword":false},"storage":{"perFileQuotaBytes":"100000000","recycle":{"allowSkip":false,"autoPurgeDays":30,"disable":false}},"users":{"externals":true,"guests":false}},"properties":{"channels":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"collabora":{"$ref":"#/components/schemas/CellsCollaboraStatus_MTgzNTQyNzUz"},"groups":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"metadata":{"$ref":"#/components/schemas/CellsMetadata_LTY1OTM5MTM0"},"one2one":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"publicLinks":{"$ref":"#/components/schemas/CellsPublicLinks_MjgxMzQ3Mzk4"},"storage":{"$ref":"#/components/schemas/CellsConfigStorage_LTM0NDMwODM4"},"users":{"$ref":"#/components/schemas/CellsUsers_LTQ4NTEyODA1"}},"required":["channels","groups","one2one","users","collabora","publicLinks","storage","metadata"],"type":"object"},"LockableFeature_CellsConfigB_Bare_Identity_LTgzMDA1NjI3":{"properties":{"config":{"$ref":"#/components/schemas/CellsConfigB_Covered_Identity_LTE1NzkwOTcz"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"AllowedGlobalOperationsConfig_MzAwOTU1MDkx":{"properties":{"mlsConversationReset":{"type":"boolean"}},"required":["mlsConversationReset"],"type":"object"},"LockableFeature_AllowedGlobalOperationsConfig_NjQ1MjA5MDYw":{"properties":{"config":{"$ref":"#/components/schemas/AllowedGlobalOperationsConfig_MzAwOTU1MDkx"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_ConsumableNotificationsConfig_MjUxMjczMjM0":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_ChatBubblesConfig_NDgwMTQzNjI2":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_AppsConfig_MzQyNTMxNTk5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFtur_SimpfdUsCnRqQg_Njk5NjU4OTgy":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_AssetAuditLogConfig_NDQ3MzcyMzk2":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_StealthUsersConfig_LTE1MTk2NzIz":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"CellsBackend_LTE1Nzg3NzQ2":{"properties":{"url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["url"],"type":"object"},"CollaboraEdition_LTg2NDA1NDQ4":{"enum":["NO","CODE","COOL"],"type":"string"},"CellsCollabora_LTMzNDA5MDIz":{"properties":{"edition":{"$ref":"#/components/schemas/CollaboraEdition_LTg2NDA1NDQ4"}},"required":["edition"],"type":"object"},"CellsStorage_LTY2Mzc5NzY1":{"properties":{"perUserQuotaBytes":{"example":"-1","type":"string"},"totalLimitBytes":{"example":"-1","type":"string"}},"required":["perUserQuotaBytes"],"type":"object"},"CellsInternalConfigB_Covered_Identity_LTUzMDkwOTAz":{"properties":{"backend":{"$ref":"#/components/schemas/CellsBackend_LTE1Nzg3NzQ2"},"collabora":{"$ref":"#/components/schemas/CellsCollabora_LTMzNDA5MDIz"},"storage":{"$ref":"#/components/schemas/CellsStorage_LTY2Mzc5NzY1"}},"required":["backend","collabora","storage"],"type":"object"},"LockableFtur_CsInfigBdy_LTQ3MTU3Mjg0":{"properties":{"config":{"$ref":"#/components/schemas/CellsInternalConfigB_Covered_Identity_LTUzMDkwOTAz"},"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus","config"],"type":"object"},"LockableFeature_MeetingsConfig_LTE0OTQ5Nzcw":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_MeetingsPremiumConfig_NDg1ODEyNTA1":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"LockableFeature_BackgroundEffectsConfig_MTg1MTk3NTM5":{"properties":{"lockStatus":{"$ref":"#/components/schemas/LockStatus_LTIyMTU5OTkw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","lockStatus"],"type":"object"},"Feature_ChannelsConfigB_Bare_Identity_LTU4NTE4MTgx":{"properties":{"config":{"$ref":"#/components/schemas/ChannelsConfigB_Covered_Identity_ODk2MTk3NDQ4"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Featur_Vsiond_17PvAmlGpCfgBIy_LTIzMjQ4MDk1V17":{"properties":{"config":{"$ref":"#/components/schemas/Versiond_17_PvtAmlGupCfgBaIy_LTY1MTkyNDcw"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Versiond_17_PvtAmlGupCfgBaIy_LTY1MTkyNDcw":{"properties":{"deletionTimeoutDuration":{"type":"string"},"promotionStrategy":{"$ref":"#/components/schemas/PreventAdminlessGroupsPromotionStrategy_Mjc1ODA2MDU1"},"reminderTimeoutDurations":{"items":{"type":"string"},"type":"array"}},"required":["promotionStrategy","deletionTimeoutDuration","reminderTimeoutDurations"],"type":"object"},"Feature_CellsConfigB_Bare_Identity_NzU2NjgxNzEw":{"properties":{"config":{"$ref":"#/components/schemas/CellsConfigB_Covered_Identity_LTE1NzkwOTcz"},"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status","config"],"type":"object"},"Feature_MeetingsConfig_NDc2MzM0MDE1":{"properties":{"status":{"$ref":"#/components/schemas/FeatureStatus_LTMzMTUwODEw"},"ttl":{"example":"unlimited","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["status"],"type":"object"},"MLSMessageSendingStatus_NjA1NDA0MTE4":{"properties":{"events":{"description":"A list of events caused by sending the message.","items":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"},"type":"array"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["events","time"],"type":"object"},"MLSMessage":{"description":"This object can only be parsed in TLS format. Please refer to the MLS specification for details."},"CommitBundle":{"description":"This object can only be parsed in TLS format. Please refer to the MLS specification for details."},"MeetingWithConversation_LTMyNzA4NzU0":{"description":"A scheduled meeting with its associated conversation","properties":{"conversation":{"$ref":"#/components/schemas/Conversation_GroupConvType_MzQzMTQ1OTg3"},"created_at":{"$ref":"#/components/schemas/UTCTime"},"end_time":{"$ref":"#/components/schemas/UTCTime"},"invited_emails":{"items":{"$ref":"#/components/schemas/Email"},"type":"array"},"qualified_conversation":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_creator":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Meeting_MjgxMDUyNjc3"},"recurrence":{"$ref":"#/components/schemas/Recurrence_LTQ0OTc0ODE2"},"start_time":{"$ref":"#/components/schemas/UTCTime"},"title":{"maxLength":256,"minLength":1,"type":"string"},"tzid":{"$ref":"#/components/schemas/TimeZone"},"updated_at":{"$ref":"#/components/schemas/UTCTime"}},"required":["qualified_id","title","qualified_creator","start_time","end_time","tzid","qualified_conversation","invited_emails","created_at","updated_at","conversation"],"type":"object"},"Qualified_Id_IdTag_Meeting_MjgxMDUyNjc3":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","id"],"type":"object"},"TimeZone":{"type":"string"},"Frequency_Mzk0ODQwOTM3":{"enum":["daily","weekly","monthly","yearly"],"type":"string"},"Recurrence_LTQ0OTc0ODE2":{"description":"Recurrence pattern for meetings","properties":{"frequency":{"$ref":"#/components/schemas/Frequency_Mzk0ODQwOTM3"},"interval":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"until":{"$ref":"#/components/schemas/UTCTime"}},"required":["frequency"],"type":"object"},"NewMeeting_LTI1NTMzOTU5":{"description":"Request to create a new meeting","properties":{"end_time":{"$ref":"#/components/schemas/UTCTime"},"invited_emails":{"items":{"$ref":"#/components/schemas/Email"},"type":"array"},"recurrence":{"$ref":"#/components/schemas/Recurrence_LTQ0OTc0ODE2"},"start_time":{"$ref":"#/components/schemas/UTCTime"},"title":{"maxLength":256,"minLength":1,"type":"string"},"tzid":{"$ref":"#/components/schemas/TimeZone"}},"required":["start_time","end_time","tzid","title"],"type":"object"},"UpdateMeeting_NTExNzYxMTcz":{"description":"Request to update a meeting","properties":{"end_time":{"$ref":"#/components/schemas/UTCTime"},"recurrence":{"$ref":"#/components/schemas/Recurrence_LTQ0OTc0ODE2"},"start_time":{"$ref":"#/components/schemas/UTCTime"},"title":{"maxLength":256,"minLength":1,"type":"string"}},"type":"object"},"Meeting_ODU0OTMzMTgw":{"description":"A scheduled meeting","properties":{"created_at":{"$ref":"#/components/schemas/UTCTime"},"end_time":{"$ref":"#/components/schemas/UTCTime"},"invited_emails":{"items":{"$ref":"#/components/schemas/Email"},"type":"array"},"qualified_conversation":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Conversation_LTQ5NDQwNjc5"},"qualified_creator":{"$ref":"#/components/schemas/Qualified_Id_IdTag_User_LTQ1NTIwNDM1"},"qualified_id":{"$ref":"#/components/schemas/Qualified_Id_IdTag_Meeting_MjgxMDUyNjc3"},"recurrence":{"$ref":"#/components/schemas/Recurrence_LTQ0OTc0ODE2"},"start_time":{"$ref":"#/components/schemas/UTCTime"},"title":{"maxLength":256,"minLength":1,"type":"string"},"tzid":{"$ref":"#/components/schemas/TimeZone"},"updated_at":{"$ref":"#/components/schemas/UTCTime"}},"required":["qualified_id","title","qualified_creator","start_time","end_time","tzid","qualified_conversation","invited_emails","created_at","updated_at"],"type":"object"},"MeetingEmailsInvitation_NzgyNzUzMzcz":{"description":"Emails invitation","properties":{"emails":{"items":{"$ref":"#/components/schemas/Email"},"type":"array"}},"required":["emails"],"type":"object"},"CustomBackend_LTQxODI0MjQ0":{"description":"Description of a custom backend","properties":{"config_json_url":{"$ref":"#/components/schemas/HttpsUrl"},"webapp_welcome_url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["config_json_url","webapp_welcome_url"],"type":"object"},"ViewLegalHoldService_LTE3MzQzNDkw":{"properties":{"settings":{"$ref":"#/components/schemas/ViewLegalHoldServiceInfo_LTc3NjI2MzQ3"},"status":{"$ref":"#/components/schemas/LHServiceStatus_ODc3NzE0Mjg3"}},"required":["status"],"type":"object"},"LHServiceStatus_ODc3NzE0Mjg3":{"enum":["configured","not_configured","disabled"],"type":"string"},"Fingerprint":{"example":"ioy3GeIjgQRsobf2EKGO3O8mq/FofFxHRqy0T4ERIZ8=","type":"string"},"ViewLegalHoldServiceInfo_LTc3NjI2MzQ3":{"properties":{"auth_token":{"$ref":"#/components/schemas/ASCII"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"fingerprint":{"$ref":"#/components/schemas/Fingerprint"},"public_key":{"$ref":"#/components/schemas/ServiceKeyPEM"},"team_id":{"$ref":"#/components/schemas/UUID"}},"required":["team_id","base_url","fingerprint","auth_token","public_key"],"type":"object"},"NewLegalHoldService_Mzg0ODQ5NDU1":{"properties":{"auth_token":{"$ref":"#/components/schemas/ASCII"},"base_url":{"$ref":"#/components/schemas/HttpsUrl"},"public_key":{"$ref":"#/components/schemas/ServiceKeyPEM"}},"required":["base_url","public_key","auth_token"],"type":"object"},"RemoveLegalHoldSettingsRequest_NzMzMzMyMDAz":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"UserLegalHoldStatusResponse_LTQ1MzUxMTE3":{"properties":{"client":{"$ref":"#/components/schemas/IdObject_ClientId_LTM3NjQyODM5"},"last_prekey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"},"status":{"$ref":"#/components/schemas/UserLegalHoldStatus_LTQ2ODA2NTU5"}},"required":["status"],"type":"object"},"IdObject_ClientId_LTM3NjQyODM5":{"properties":{"id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"}},"required":["id"],"type":"object"},"DisableLegalHoldForUserRequest_LTYyMDYxOTEy":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"ApproveLegalHoldForUserRequest_NjEyNzYyMTIx":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"TeamMembersPage_NzYwNDIxODgx":{"properties":{"hasMore":{"type":"boolean"},"members":{"items":{"$ref":"#/components/schemas/TeamMember_Optional_NTU0MDcyNzI1"},"type":"array"},"pagingState":{"$ref":"#/components/schemas/TeamMembers_PagingState"}},"required":["members","hasMore","pagingState"],"type":"object"},"Permissions_NDE0ODM5NDUx":{"description":"This is just a complicated way of representing a team role. self and copy always have to contain the same integer, and only the following integers are allowed: 1025 (partner), 1587 (member), 5951 (admin), 8191 (owner). Unit tests of the galley-types package in wire-server contain an authoritative list.","properties":{"copy":{"description":"Permissions that this user is able to grant others","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"},"self":{"description":"Permissions that the user has","format":"int64","maximum":18446744073709551615,"minimum":0,"type":"integer"}},"required":["self","copy"],"type":"object"},"TeamMember_Optional_NTU0MDcyNzI1":{"description":"team member data","properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"legalhold_status":{"$ref":"#/components/schemas/UserLegalHoldStatus_LTQ2ODA2NTU5"},"permissions":{"$ref":"#/components/schemas/Permissions_NDE0ODM5NDUx"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user"],"type":"object"},"TeamMembers_PagingState":{"type":"string"},"TeamMemberList_Optional_LTM1ODE2MzM0":{"description":"list of team member","properties":{"hasMore":{"$ref":"#/components/schemas/ListType_LTkyMDM4MzA1"},"members":{"description":"the array of team members","items":{"$ref":"#/components/schemas/TeamMember_Optional_NTU0MDcyNzI1"},"type":"array"}},"required":["members","hasMore"],"type":"object"},"ListType_LTkyMDM4MzA1":{"description":"true if 'members' doesn't contain all team members","enum":[true,false],"type":"boolean"},"UserIdList_MzA1MTI1Njgx":{"properties":{"user_ids":{"items":{"$ref":"#/components/schemas/UUID"},"type":"array"}},"required":["user_ids"],"type":"object"},"TeamMemberDeleteData_LTg2OTEyOTI4":{"description":"Data for a team member deletion request in case of binding teams.","properties":{"password":{"description":"The account password to authorise the deletion.","maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"NewTeamMember_Required_LTg2NjU5OTI2":{"description":"Required data when creating new team members","properties":{"member":{"description":"the team member to add (the legalhold_status field must be null or missing!)","properties":{"created_at":{"$ref":"#/components/schemas/UTCTimeMillis"},"created_by":{"$ref":"#/components/schemas/UUID"},"permissions":{"$ref":"#/components/schemas/Permissions_NDE0ODM5NDUx"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","permissions"],"type":"object"}},"required":["member"],"type":"object"},"NewTeamCollaborator_LTIxNjEzMTYw":{"properties":{"permissions":{"items":{"$ref":"#/components/schemas/CollaboratorPermission_NDg5NTg2ODgy"},"type":"array"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","permissions"],"type":"object"},"CollaboratorPermission_NDg5NTg2ODgy":{"description":"

Permission granted to a team collaborator.

  • `create_team_conversation`: equivalent to the `CreateConversation` and `AddRemoveConvMember` permissions for team members (both implied in the `member` role); allows creating team group conversations and adding members to them.
  • \n
  • `implicit_connection`: team members are implicitly connected to each other, allowing conversations (1:1 or group) without an explicit connection request. This permission grants the same to a collaborator.
\n

NB: a member of team A can always open conversations with a collaborator of team A; the permission only controls the collaborator's abilities.

","enum":["create_team_conversation","implicit_connection"],"type":"string"},"TeamCollaborator_LTI3MzM1MTYz":{"properties":{"permissions":{"items":{"$ref":"#/components/schemas/CollaboratorPermission_NDg5NTg2ODgy"},"type":"array"},"team":{"$ref":"#/components/schemas/UUID"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","team","permissions"],"type":"object"},"QueuedNotificationList_MTU0ODEyNTQ2":{"description":"Zero or more notifications","properties":{"has_more":{"description":"Whether there are still more notifications.","type":"boolean"},"notifications":{"description":"Notifications","items":{"$ref":"#/components/schemas/QueuedNotification_NTY2NzY2MTU2"},"type":"array"},"time":{"$ref":"#/components/schemas/UTCTime"}},"required":["notifications"],"type":"object"},"Object":{"additionalProperties":true,"description":"A single notification event","properties":{"type":{"description":"Event type","type":"string"}},"title":"Event","type":"object"},"QueuedNotification_NTY2NzY2MTU2":{"description":"A single notification","properties":{"id":{"$ref":"#/components/schemas/UUID"},"payload":{"description":"List of events","items":{"$ref":"#/components/schemas/Object"},"minItems":1,"type":"array"}},"required":["id","payload"],"type":"object"},"FormRedirect":{"properties":{"uri":{"type":"string"},"xml":{"$ref":"#/components/schemas/AuthnRequest"}},"type":"object"},"AuthnRequest":{"properties":{"iD":{"$ref":"#/components/schemas/Id_AuthnRequest"},"issueInstant":{"$ref":"#/components/schemas/Time"},"issuer":{"$ref":"#/components/schemas/URI"},"nameIDPolicy":{"$ref":"#/components/schemas/NameIdPolicy"}},"required":["iD","issueInstant","issuer"],"type":"object"},"Id_AuthnRequest":{"properties":{"iD":{"type":"string"}},"required":["iD"],"type":"object"},"Time":{"properties":{"time":{"$ref":"#/components/schemas/UTCTime"}},"required":["time"],"type":"object"},"URI":{"type":"string"},"NameIdPolicy":{"properties":{"allowCreate":{"type":"boolean"},"format":{"$ref":"#/components/schemas/NameIDFormat"},"spNameQualifier":{"type":"string"}},"required":["format","allowCreate"],"type":"object"},"NameIDFormat":{"enum":["NameIDFUnspecified","NameIDFEmail","NameIDFX509","NameIDFWindows","NameIDFKerberos","NameIDFEntity","NameIDFPersistent","NameIDFTransient"],"type":"string"},"SsoSettings":{"properties":{"default_sso_code":{"$ref":"#/components/schemas/URI"}},"type":"object"},"GetByEmailResp_LTMxNTY3MjA0":{"properties":{"sso_code":{"$ref":"#/components/schemas/UUID"}},"type":"object"},"GetByEmailReq_LTY4MzE3Njgy":{"properties":{"email":{"$ref":"#/components/schemas/Email"}},"required":["email"],"type":"object"},"IdPConfig_WireIdP_NDA5MTE4Mjk0":{"properties":{"extraInfo":{"$ref":"#/components/schemas/WireIdP_ODMzOTExMzYw"},"id":{"$ref":"#/components/schemas/URI"},"metadata":{"$ref":"#/components/schemas/IdPMetadata_MTI3NzE4MTA0"}},"required":["id","metadata","extraInfo"],"type":"object"},"SignedCertificate":{"type":"string"},"IdPMetadata_MTI3NzE4MTA0":{"properties":{"certAuthnResponse":{"items":{"$ref":"#/components/schemas/SignedCertificate"},"minItems":1,"type":"array"},"issuer":{"$ref":"#/components/schemas/URI"},"requestURI":{"type":"string"}},"required":["issuer","requestURI","certAuthnResponse"],"type":"object"},"WireIdPAPIVersion_NTEyMzIwNTU3":{"enum":["WireIdPAPIV1","WireIdPAPIV2"],"type":"string"},"WireIdP_ODMzOTExMzYw":{"properties":{"apiVersion":{"enum":["WireIdPAPIV1","WireIdPAPIV2"],"type":"string"},"domain":{"example":"example.com","type":"string"},"handle":{"type":"string"},"oldIssuers":{"items":{"$ref":"#/components/schemas/URI"},"type":"array"},"replacedBy":{"type":"string"},"team":{"$ref":"#/components/schemas/UUID"}},"required":["team","apiVersion","oldIssuers","replacedBy","handle","domain"],"type":"object"},"IdPList":{"properties":{"providers":{"items":{"$ref":"#/components/schemas/IdPConfig_WireIdP_NDA5MTE4Mjk0"},"type":"array"}},"required":["providers"],"type":"object"},"IdPMetadataInfo":{"maxProperties":1,"minProperties":1,"properties":{"value":{"type":"string"}},"type":"object"},"CreateScimTokenResponse_LTIzOTU2NDU4":{"properties":{"info":{"$ref":"#/components/schemas/ScimTokenInfo_LTI5NjgwNzA1"},"token":{"type":"string"}},"required":["token","info"],"type":"object"},"ScimTokenInfo_LTI5NjgwNzA1":{"properties":{"created_at":{"$ref":"#/components/schemas/UTCTime"},"description":{"type":"string"},"id":{"$ref":"#/components/schemas/UUID"},"idp":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"team":{"$ref":"#/components/schemas/UUID"}},"required":["team","id","created_at","description","name"],"type":"object"},"CreateScimToken_OTY0NjYxMDQ2":{"properties":{"description":{"type":"string"},"idp":{"$ref":"#/components/schemas/UUID"},"name":{"type":"string"},"password":{"maxLength":1024,"minLength":6,"type":"string"},"verification_code":{"$ref":"#/components/schemas/ASCII"}},"required":["description"],"type":"object"},"ScimTokenName_LTgzOTM2OTI4":{"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"},"ScimTokenList_NjQwNTYxOTAw":{"properties":{"tokens":{"items":{"$ref":"#/components/schemas/ScimTokenInfo_LTI5NjgwNzA1"},"type":"array"}},"required":["tokens"],"type":"object"},"Asset_Qualified_AssetKey_MzU1MjMxNTA5":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"expires":{"$ref":"#/components/schemas/UTCTimeMillis"},"key":{"$ref":"#/components/schemas/AssetKey"},"token":{"$ref":"#/components/schemas/ASCII"}},"required":["key","domain"],"type":"object"},"AssetSource":{},"NewAssetToken_NTAwMDQwODYy":{"properties":{"token":{"$ref":"#/components/schemas/ASCII"}},"required":["token"],"type":"object"},"PushToken_ODYzMDYzOTA4":{"description":"Native Push Token","properties":{"app":{"description":"Application","type":"string"},"client":{"description":"Client ID","type":"string"},"token":{"description":"Access Token","type":"string"},"transport":{"$ref":"#/components/schemas/Transport_NDk2NzU5NDIy"}},"required":["transport","app","token","client"],"type":"object"},"Transport_NDk2NzU5NDIy":{"description":"Transport","enum":["GCM","APNS","APNS_SANDBOX","APNS_VOIP","APNS_VOIP_SANDBOX"],"type":"string"},"PushTokenList_NDI0Mjc3MzY3":{"description":"List of Native Push Tokens","properties":{"tokens":{"description":"Push tokens","items":{"$ref":"#/components/schemas/PushToken_ODYzMDYzOTA4"},"type":"array"}},"required":["tokens"],"type":"object"},"ServerTime_LTM4NTI3MzIx":{"description":"The current server time","properties":{"time":{"$ref":"#/components/schemas/UTCTime"}},"required":["time"],"type":"object"}},"securitySchemes":{"ZAuth":{"description":"Must be a token retrieved by calling 'POST /login' or 'POST /access'. It must be presented in this format: 'Bearer \\'.","in":"header","name":"Authorization","type":"apiKey"}}},"security":[{"ZAuth":[]}],"openapi":"3.0.0"} \ No newline at end of file +{ + "components": { + "schemas": { + "": { + "enum": [ + "audio", + "books", + "business", + "design", + "education", + "entertainment", + "finance", + "fitness", + "food-drink", + "games", + "graphics", + "health", + "integration", + "lifestyle", + "media", + "medical", + "movies", + "music", + "news", + "photography", + "poll", + "productivity", + "quiz", + "rating", + "shopping", + "social", + "sports", + "travel", + "tutorial", + "video", + "weather" + ], + "type": "string" + }, + "ASCII": { + "example": "aGVsbG8", + "type": "string" + }, + "Access": { + "description": "How users can join conversations", + "enum": [ + "private", + "invite", + "link", + "code" + ], + "type": "string" + }, + "AccessRole": { + "description": "Which users/services can join conversations. This replaces legacy access roles and allows a more fine grained configuration of access roles, and in particular a separation of guest and services access.\n\nThis field is optional. If it is not present, the default will be `[team_member, non_team_member, service]`. Please note that an empty list is not allowed when creating a new conversation.", + "enum": [ + "team_member", + "non_team_member", + "guest", + "service" + ], + "type": "string" + }, + "AccessRoleLegacy": { + "deprecated": true, + "description": "Deprecated, please use access_role_v2", + "enum": [ + "private", + "team", + "activated", + "non_activated" + ], + "type": "string" + }, + "AccessToken": { + "properties": { + "access_token": { + "description": "The opaque access token string", + "type": "string" + }, + "expires_in": { + "description": "The number of seconds this token is valid", + "type": "integer" + }, + "token_type": { + "$ref": "#/components/schemas/TokenType" + }, + "user": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "user", + "access_token", + "token_type", + "expires_in" + ], + "type": "object" + }, + "AccessTokenType": { + "enum": [ + "DPoP" + ], + "type": "string" + }, + "Action": { + "enum": [ + "add_conversation_member", + "remove_conversation_member", + "modify_conversation_name", + "modify_conversation_message_timer", + "modify_conversation_receipt_mode", + "modify_conversation_access", + "modify_other_conversation_member", + "leave_conversation", + "delete_conversation" + ], + "type": "string" + }, + "Activate": { + "description": "Data for an activation request.", + "properties": { + "code": { + "$ref": "#/components/schemas/ASCII" + }, + "dryrun": { + "description": "At least one of key, email, or phone has to be present while key takes precedence over email, and email takes precedence over phone. Whether to perform a dryrun, i.e. to only check whether activation would succeed. Dry-runs never issue access cookies or tokens on success but failures still count towards the maximum failure count.", + "type": "boolean" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "key": { + "$ref": "#/components/schemas/ASCII" + } + }, + "required": [ + "code", + "dryrun" + ], + "type": "object" + }, + "ActivationResponse": { + "description": "Response body of a successful activation request", + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + }, + "first": { + "description": "Whether this is the first successful activation (i.e. account activation).", + "type": "boolean" + }, + "sso_id": { + "$ref": "#/components/schemas/UserSSOId" + } + }, + "type": "object" + }, + "AddBot": { + "properties": { + "locale": { + "$ref": "#/components/schemas/Locale" + }, + "provider": { + "$ref": "#/components/schemas/UUID" + }, + "service": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "provider", + "service" + ], + "type": "object" + }, + "AddBotResponse": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "client": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "event": { + "$ref": "#/components/schemas/Event" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id", + "client", + "name", + "accent_id", + "assets", + "event" + ], + "type": "object" + }, + "AllTeamFeatures": { + "properties": { + "appLock": { + "$ref": "#/components/schemas/AppLockConfig.LockableFeature" + }, + "classifiedDomains": { + "$ref": "#/components/schemas/ClassifiedDomainsConfig.LockableFeature" + }, + "conferenceCalling": { + "$ref": "#/components/schemas/ConferenceCallingConfig.LockableFeature" + }, + "conversationGuestLinks": { + "$ref": "#/components/schemas/GuestLinksConfig.LockableFeature" + }, + "digitalSignatures": { + "$ref": "#/components/schemas/DigitalSignaturesConfig.LockableFeature" + }, + "enforceFileDownloadLocation": { + "$ref": "#/components/schemas/EnforceFileDownloadLocation.LockableFeature" + }, + "exposeInvitationURLsToTeamAdmin": { + "$ref": "#/components/schemas/ExposeInvitationURLsToTeamAdminConfig.LockableFeature" + }, + "fileSharing": { + "$ref": "#/components/schemas/FileSharingConfig.LockableFeature" + }, + "legalhold": { + "$ref": "#/components/schemas/LegalholdConfig.LockableFeature" + }, + "limitedEventFanout": { + "$ref": "#/components/schemas/LimitedEventFanoutConfig.LockableFeature" + }, + "mls": { + "$ref": "#/components/schemas/MLSConfig.LockableFeature" + }, + "mlsE2EId": { + "$ref": "#/components/schemas/MlsE2EIdConfig.LockableFeature" + }, + "mlsMigration": { + "$ref": "#/components/schemas/MlsMigration.LockableFeature" + }, + "outlookCalIntegration": { + "$ref": "#/components/schemas/OutlookCalIntegrationConfig.LockableFeature" + }, + "searchVisibility": { + "$ref": "#/components/schemas/SearchVisibilityAvailableConfig.LockableFeature" + }, + "searchVisibilityInbound": { + "$ref": "#/components/schemas/SearchVisibilityInboundConfig.LockableFeature" + }, + "selfDeletingMessages": { + "$ref": "#/components/schemas/SelfDeletingMessagesConfig.LockableFeature" + }, + "sndFactorPasswordChallenge": { + "$ref": "#/components/schemas/SndFactorPasswordChallengeConfig.LockableFeature" + }, + "sso": { + "$ref": "#/components/schemas/SSOConfig.LockableFeature" + }, + "validateSAMLemails": { + "$ref": "#/components/schemas/ValidateSAMLEmailsConfig.LockableFeature" + } + }, + "required": [ + "legalhold", + "sso", + "searchVisibility", + "searchVisibilityInbound", + "validateSAMLemails", + "digitalSignatures", + "appLock", + "fileSharing", + "classifiedDomains", + "conferenceCalling", + "selfDeletingMessages", + "conversationGuestLinks", + "sndFactorPasswordChallenge", + "mls", + "exposeInvitationURLsToTeamAdmin", + "outlookCalIntegration", + "mlsE2EId", + "mlsMigration", + "enforceFileDownloadLocation", + "limitedEventFanout" + ], + "type": "object" + }, + "Alpha": { + "enum": [ + "AED", + "AFN", + "ALL", + "AMD", + "ANG", + "AOA", + "ARS", + "AUD", + "AWG", + "AZN", + "BAM", + "BBD", + "BDT", + "BGN", + "BHD", + "BIF", + "BMD", + "BND", + "BOB", + "BOV", + "BRL", + "BSD", + "BTN", + "BWP", + "BYN", + "BZD", + "CAD", + "CDF", + "CHE", + "CHF", + "CHW", + "CLF", + "CLP", + "CNY", + "COP", + "COU", + "CRC", + "CUC", + "CUP", + "CVE", + "CZK", + "DJF", + "DKK", + "DOP", + "DZD", + "EGP", + "ERN", + "ETB", + "EUR", + "FJD", + "FKP", + "GBP", + "GEL", + "GHS", + "GIP", + "GMD", + "GNF", + "GTQ", + "GYD", + "HKD", + "HNL", + "HRK", + "HTG", + "HUF", + "IDR", + "ILS", + "INR", + "IQD", + "IRR", + "ISK", + "JMD", + "JOD", + "JPY", + "KES", + "KGS", + "KHR", + "KMF", + "KPW", + "KRW", + "KWD", + "KYD", + "KZT", + "LAK", + "LBP", + "LKR", + "LRD", + "LSL", + "LYD", + "MAD", + "MDL", + "MGA", + "MKD", + "MMK", + "MNT", + "MOP", + "MRO", + "MUR", + "MVR", + "MWK", + "MXN", + "MXV", + "MYR", + "MZN", + "NAD", + "NGN", + "NIO", + "NOK", + "NPR", + "NZD", + "OMR", + "PAB", + "PEN", + "PGK", + "PHP", + "PKR", + "PLN", + "PYG", + "QAR", + "RON", + "RSD", + "RUB", + "RWF", + "SAR", + "SBD", + "SCR", + "SDG", + "SEK", + "SGD", + "SHP", + "SLL", + "SOS", + "SRD", + "SSP", + "STD", + "SVC", + "SYP", + "SZL", + "THB", + "TJS", + "TMT", + "TND", + "TOP", + "TRY", + "TTD", + "TWD", + "TZS", + "UAH", + "UGX", + "USD", + "USN", + "UYI", + "UYU", + "UZS", + "VEF", + "VND", + "VUV", + "WST", + "XAF", + "XAG", + "XAU", + "XBA", + "XBB", + "XBC", + "XBD", + "XCD", + "XDR", + "XOF", + "XPD", + "XPF", + "XPT", + "XSU", + "XTS", + "XUA", + "XXX", + "YER", + "ZAR", + "ZMW", + "ZWL" + ], + "type": "string" + }, + "AppLockConfig": { + "properties": { + "enforceAppLock": { + "type": "boolean" + }, + "inactivityTimeoutSecs": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "required": [ + "enforceAppLock", + "inactivityTimeoutSecs" + ], + "type": "object" + }, + "AppLockConfig.Feature": { + "properties": { + "config": { + "$ref": "#/components/schemas/AppLockConfig" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "config" + ], + "type": "object" + }, + "AppLockConfig.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/AppLockConfig" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "ApproveLegalHoldForUserRequest": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "Asset": { + "properties": { + "domain": { + "$ref": "#/components/schemas/Domain" + }, + "expires": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "key": { + "$ref": "#/components/schemas/AssetKey" + }, + "token": { + "$ref": "#/components/schemas/ASCII" + } + }, + "required": [ + "key", + "domain" + ], + "type": "object" + }, + "AssetKey": { + "example": "3-1-47de4580-ae51-4650-acbb-d10c028cb0ac", + "type": "string" + }, + "AssetSize": { + "enum": [ + "preview", + "complete" + ], + "type": "string" + }, + "AssetSource": {}, + "AssetType": { + "enum": [ + "image" + ], + "type": "string" + }, + "AuthnRequest": { + "properties": { + "iD": { + "$ref": "#/components/schemas/ID_*_AuthnRequest" + }, + "issueInstant": { + "$ref": "#/components/schemas/Time" + }, + "issuer": { + "type": "string" + }, + "nameIDPolicy": { + "$ref": "#/components/schemas/NameIdPolicy" + } + }, + "required": [ + "iD", + "issueInstant", + "issuer" + ], + "type": "object" + }, + "Base64ByteString": { + "example": "ZXhhbXBsZQo=", + "type": "string" + }, + "BaseProtocol": { + "enum": [ + "proteus", + "mls" + ], + "type": "string" + }, + "BindingNewTeamUser": { + "properties": { + "currency": { + "$ref": "#/components/schemas/Alpha" + }, + "icon": { + "$ref": "#/components/schemas/Icon" + }, + "icon_key": { + "description": "team icon asset key", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "members": { + "description": "initial team member ids (between 1 and 127)" + }, + "name": { + "description": "team name", + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name", + "icon" + ], + "type": "object" + }, + "Body": {}, + "BotConvView": { + "properties": { + "id": { + "$ref": "#/components/schemas/UUID" + }, + "members": { + "items": { + "$ref": "#/components/schemas/OtherMember" + }, + "type": "array" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "members" + ], + "type": "object" + }, + "BotUserView": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "handle": { + "$ref": "#/components/schemas/Handle" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "team": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "id", + "name", + "accent_id" + ], + "type": "object" + }, + "CheckHandles": { + "properties": { + "handles": { + "items": { + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array" + }, + "return": { + "maximum": 10, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "handles", + "return" + ], + "type": "object" + }, + "CipherSuiteTag": { + "description": "The cipher suite of the corresponding MLS group", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "ClassifiedDomainsConfig": { + "properties": { + "domains": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "domains" + ], + "type": "object" + }, + "ClassifiedDomainsConfig.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/ClassifiedDomainsConfig" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "Client": { + "properties": { + "capabilities": { + "$ref": "#/components/schemas/ClientCapabilityList" + }, + "class": { + "$ref": "#/components/schemas/ClientClass" + }, + "cookie": { + "type": "string" + }, + "id": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "label": { + "type": "string" + }, + "last_active": { + "$ref": "#/components/schemas/UTCTime" + }, + "mls_public_keys": { + "$ref": "#/components/schemas/MLSPublicKeys" + }, + "model": { + "type": "string" + }, + "time": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "type": { + "$ref": "#/components/schemas/ClientType" + } + }, + "required": [ + "id", + "type", + "time" + ], + "type": "object" + }, + "ClientCapability": { + "enum": [ + "legalhold-implicit-consent" + ], + "type": "string" + }, + "ClientCapabilityList": { + "properties": { + "capabilities": { + "description": "Hints provided by the client for the backend so it can behave in a backwards-compatible way.", + "items": { + "$ref": "#/components/schemas/ClientCapability" + }, + "type": "array" + } + }, + "required": [ + "capabilities" + ], + "type": "object" + }, + "ClientClass": { + "enum": [ + "phone", + "tablet", + "desktop", + "legalhold" + ], + "type": "string" + }, + "ClientIdentity": { + "properties": { + "client_id": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "domain": { + "$ref": "#/components/schemas/Domain" + }, + "user_id": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "domain", + "user_id", + "client_id" + ], + "type": "object" + }, + "ClientListv6": { + "items": { + "$ref": "#/components/schemas/Client" + }, + "type": "array" + }, + "ClientMismatch": { + "properties": { + "deleted": { + "$ref": "#/components/schemas/UserClients" + }, + "missing": { + "$ref": "#/components/schemas/UserClients" + }, + "redundant": { + "$ref": "#/components/schemas/UserClients" + }, + "time": { + "$ref": "#/components/schemas/UTCTimeMillis" + } + }, + "required": [ + "time", + "missing", + "redundant", + "deleted" + ], + "type": "object" + }, + "ClientPrekey": { + "properties": { + "client": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "prekey": { + "$ref": "#/components/schemas/Prekey" + } + }, + "required": [ + "client", + "prekey" + ], + "type": "object" + }, + "ClientType": { + "enum": [ + "temporary", + "permanent", + "legalhold" + ], + "type": "string" + }, + "Clientv6": { + "properties": { + "capabilities": { + "$ref": "#/components/schemas/ClientCapabilityList" + }, + "class": { + "$ref": "#/components/schemas/ClientClass" + }, + "cookie": { + "type": "string" + }, + "id": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "label": { + "type": "string" + }, + "last_active": { + "$ref": "#/components/schemas/UTCTime" + }, + "mls_public_keys": { + "$ref": "#/components/schemas/MLSPublicKeys" + }, + "model": { + "type": "string" + }, + "time": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "type": { + "$ref": "#/components/schemas/ClientType" + } + }, + "required": [ + "id", + "type", + "time" + ], + "type": "object" + }, + "CodeChallengeMethod": { + "description": "The method used to encode the code challenge. Only `S256` is supported.", + "enum": [ + "S256" + ], + "type": "string" + }, + "CommitBundle": { + "description": "This object can only be parsed in TLS format. Please refer to the MLS specification for details." + }, + "CompletePasswordReset": { + "properties": { + "code": { + "$ref": "#/components/schemas/ASCII" + }, + "key": { + "$ref": "#/components/schemas/ASCII" + }, + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "key", + "code", + "password" + ], + "type": "object" + }, + "ConferenceCallingConfig": { + "properties": { + "useSFTForOneToOneCalls": { + "type": "boolean" + } + }, + "type": "object" + }, + "ConferenceCallingConfig.Feature": { + "properties": { + "config": { + "$ref": "#/components/schemas/ConferenceCallingConfig" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "ConferenceCallingConfig.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/ConferenceCallingConfig" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "Connect": { + "properties": { + "email": { + "type": "string" + }, + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "qualified_recipient": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "recipient": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "qualified_recipient" + ], + "type": "object" + }, + "ConnectionUpdate": { + "properties": { + "status": { + "$ref": "#/components/schemas/Relation" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "Connections_Page": { + "properties": { + "connections": { + "items": { + "$ref": "#/components/schemas/UserConnection" + }, + "type": "array" + }, + "has_more": { + "type": "boolean" + }, + "paging_state": { + "$ref": "#/components/schemas/Connections_PagingState" + } + }, + "required": [ + "connections", + "has_more", + "paging_state" + ], + "type": "object" + }, + "Connections_PagingState": { + "type": "string" + }, + "Contact": { + "description": "Contact discovered through search", + "properties": { + "accent_id": { + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "handle": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "type": "string" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "team": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "qualified_id", + "name" + ], + "type": "object" + }, + "ConvMembers": { + "description": "Users of a conversation", + "properties": { + "others": { + "description": "All other current users of this conversation", + "items": { + "$ref": "#/components/schemas/OtherMember" + }, + "type": "array" + }, + "self": { + "$ref": "#/components/schemas/Member" + } + }, + "required": [ + "self", + "others" + ], + "type": "object" + }, + "ConvTeamInfo": { + "description": "Team information of this conversation", + "properties": { + "managed": { + "description": "This field MUST NOT be used by clients. It is here only for backwards compatibility of the interface." + }, + "teamid": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "teamid", + "managed" + ], + "type": "object" + }, + "ConvType": { + "enum": [ + 0, + 1, + 2, + 3 + ], + "type": "integer" + }, + "Conversation": { + "description": "A conversation object as returned from the server", + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "creator": { + "$ref": "#/components/schemas/UUID" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/components/schemas/UTCTime" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/components/schemas/ConvMembers" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "$ref": "#/components/schemas/Protocol" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "type": { + "$ref": "#/components/schemas/ConvType" + } + }, + "required": [ + "qualified_id", + "type", + "access", + "access_role", + "members", + "group_id", + "epoch" + ], + "type": "object" + }, + "ConversationAccessData": { + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + } + }, + "required": [ + "access", + "access_role" + ], + "type": "object" + }, + "ConversationAccessDataV2": { + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "$ref": "#/components/schemas/AccessRoleLegacy" + }, + "access_role_v2": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + } + }, + "required": [ + "access" + ], + "type": "object" + }, + "ConversationCode": { + "description": "Contains conversation properties to update", + "properties": { + "code": { + "$ref": "#/components/schemas/ASCII" + }, + "key": { + "$ref": "#/components/schemas/ASCII" + }, + "uri": { + "$ref": "#/components/schemas/HttpsUrl" + } + }, + "required": [ + "key", + "code" + ], + "type": "object" + }, + "ConversationCodeInfo": { + "description": "Contains conversation properties to update", + "properties": { + "code": { + "$ref": "#/components/schemas/ASCII" + }, + "has_password": { + "description": "Whether the conversation has a password", + "type": "boolean" + }, + "key": { + "$ref": "#/components/schemas/ASCII" + }, + "uri": { + "$ref": "#/components/schemas/HttpsUrl" + } + }, + "required": [ + "key", + "code", + "has_password" + ], + "type": "object" + }, + "ConversationCoverView": { + "description": "Limited view of Conversation.", + "properties": { + "has_password": { + "type": "boolean" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "has_password" + ], + "type": "object" + }, + "ConversationIds_Page": { + "properties": { + "has_more": { + "type": "boolean" + }, + "paging_state": { + "$ref": "#/components/schemas/ConversationIds_PagingState" + }, + "qualified_conversations": { + "items": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "type": "array" + } + }, + "required": [ + "qualified_conversations", + "has_more", + "paging_state" + ], + "type": "object" + }, + "ConversationIds_PagingState": { + "type": "string" + }, + "ConversationMessageTimerUpdate": { + "description": "Contains conversation properties to update", + "properties": { + "message_timer": { + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + } + }, + "type": "object" + }, + "ConversationReceiptModeUpdate": { + "description": "Contains conversation receipt mode to update to. Receipt mode tells clients whether certain types of receipts should be sent in the given conversation or not. How this value is interpreted is up to clients.", + "properties": { + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "required": [ + "receipt_mode" + ], + "type": "object" + }, + "ConversationRename": { + "properties": { + "name": { + "description": "The new conversation name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "ConversationRole": { + "properties": { + "actions": { + "description": "The set of actions allowed for this role", + "items": { + "$ref": "#/components/schemas/Action" + }, + "type": "array" + }, + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + } + } + }, + "ConversationRolesList": { + "properties": { + "conversation_roles": { + "items": { + "$ref": "#/components/schemas/ConversationRole" + }, + "type": "array" + } + }, + "required": [ + "conversation_roles" + ], + "type": "object" + }, + "ConversationV2": { + "description": "A conversation object as returned from the server", + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "$ref": "#/components/schemas/AccessRoleLegacy" + }, + "access_role_v2": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "creator": { + "$ref": "#/components/schemas/UUID" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/components/schemas/Epoch Timestamp" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/components/schemas/ConvMembers" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "$ref": "#/components/schemas/Protocol" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "type": { + "$ref": "#/components/schemas/ConvType" + } + }, + "required": [ + "qualified_id", + "type", + "access", + "members", + "group_id", + "epoch", + "epoch_timestamp", + "cipher_suite" + ], + "type": "object" + }, + "ConversationV3v3": { + "description": "A conversation object as returned from the server", + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "creator": { + "$ref": "#/components/schemas/UUID" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/components/schemas/Epoch Timestamp" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/components/schemas/ConvMembers" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "$ref": "#/components/schemas/Protocol" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "type": { + "$ref": "#/components/schemas/ConvType" + } + }, + "required": [ + "qualified_id", + "type", + "access", + "access_role", + "members", + "group_id", + "epoch", + "epoch_timestamp", + "cipher_suite" + ], + "type": "object" + }, + "ConversationV6v6": { + "description": "A conversation object as returned from the server", + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "creator": { + "$ref": "#/components/schemas/UUID" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/components/schemas/UTCTime" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/components/schemas/ConvMembers" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "$ref": "#/components/schemas/Protocol" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "type": { + "$ref": "#/components/schemas/ConvType" + } + }, + "required": [ + "qualified_id", + "type", + "access", + "access_role", + "members", + "group_id", + "epoch" + ], + "type": "object" + }, + "ConversationsResponse": { + "description": "Response object for getting metadata of a list of conversations", + "properties": { + "failed": { + "description": "The server failed to fetch these conversations, most likely due to network issues while contacting a remote server", + "items": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "type": "array" + }, + "found": { + "items": { + "$ref": "#/components/schemas/Conversation" + }, + "type": "array" + }, + "not_found": { + "description": "These conversations either don't exist or are deleted.", + "items": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "type": "array" + } + }, + "required": [ + "found", + "not_found", + "failed" + ], + "type": "object" + }, + "Cookie": { + "properties": { + "created": { + "$ref": "#/components/schemas/UTCTime" + }, + "expires": { + "$ref": "#/components/schemas/UTCTime" + }, + "id": { + "format": "int32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "label": { + "type": "string" + }, + "successor": { + "format": "int32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "type": { + "$ref": "#/components/schemas/CookieType" + } + }, + "required": [ + "id", + "type", + "created", + "expires" + ], + "type": "object" + }, + "CookieList": { + "description": "List of cookie information", + "properties": { + "cookies": { + "items": { + "$ref": "#/components/schemas/Cookie" + }, + "type": "array" + } + }, + "required": [ + "cookies" + ], + "type": "object" + }, + "CookieType": { + "enum": [ + "session", + "persistent" + ], + "type": "string" + }, + "CreateConversationCodeRequest": { + "description": "Request body for creating a conversation code", + "properties": { + "password": { + "description": "Password for accessing the conversation via guest link. Set to null or omit for no password.", + "maxLength": 1024, + "minLength": 8, + "type": "string" + } + }, + "type": "object" + }, + "CreateGroupConversationv6": { + "description": "A created group-conversation object extended with a list of failed-to-add users", + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "creator": { + "$ref": "#/components/schemas/UUID" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/components/schemas/UTCTime" + }, + "failed_to_add": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "type": "array" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/components/schemas/ConvMembers" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "$ref": "#/components/schemas/Protocol" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "type": { + "$ref": "#/components/schemas/ConvType" + } + }, + "required": [ + "qualified_id", + "type", + "access", + "access_role", + "members", + "group_id", + "epoch", + "failed_to_add" + ], + "type": "object" + }, + "CreateOAuthAuthorizationCodeRequest": { + "properties": { + "client_id": { + "$ref": "#/components/schemas/UUID" + }, + "code_challenge": { + "$ref": "#/components/schemas/OAuthCodeChallenge" + }, + "code_challenge_method": { + "$ref": "#/components/schemas/CodeChallengeMethod" + }, + "redirect_uri": { + "$ref": "#/components/schemas/RedirectUrl" + }, + "response_type": { + "$ref": "#/components/schemas/OAuthResponseType" + }, + "scope": { + "description": "The scopes which are requested to get authorization for, separated by a space", + "type": "string" + }, + "state": { + "description": "An opaque value used by the client to maintain state between the request and callback. The authorization server includes this value when redirecting the user-agent back to the client. The parameter SHOULD be used for preventing cross-site request forgery", + "type": "string" + } + }, + "required": [ + "client_id", + "scope", + "response_type", + "redirect_uri", + "state", + "code_challenge_method", + "code_challenge" + ], + "type": "object" + }, + "CreateScimToken": { + "properties": { + "description": { + "type": "string" + }, + "password": { + "type": "string" + }, + "verification_code": { + "type": "string" + } + }, + "required": [ + "description" + ], + "type": "object" + }, + "CreateScimTokenResponse": { + "properties": { + "info": { + "$ref": "#/components/schemas/ScimTokenInfo" + }, + "token": { + "description": "Authentication token", + "type": "string" + } + }, + "required": [ + "token", + "info" + ], + "type": "object" + }, + "CustomBackend": { + "description": "Description of a custom backend", + "properties": { + "config_json_url": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "webapp_welcome_url": { + "$ref": "#/components/schemas/HttpsUrl" + } + }, + "required": [ + "config_json_url", + "webapp_welcome_url" + ], + "type": "object" + }, + "DPoPAccessToken": { + "type": "string" + }, + "DPoPAccessTokenResponse": { + "properties": { + "expires_in": { + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "token": { + "$ref": "#/components/schemas/DPoPAccessToken" + }, + "type": { + "$ref": "#/components/schemas/AccessTokenType" + } + }, + "required": [ + "token", + "type", + "expires_in" + ], + "type": "object" + }, + "DeleteClient": { + "properties": { + "password": { + "description": "The password of the authenticated user for verification. The password is not required for deleting temporary clients.", + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "DeleteKeyPackages": { + "properties": { + "key_packages": { + "items": { + "$ref": "#/components/schemas/KeyPackageRef" + }, + "maxItems": 1000, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "key_packages" + ], + "type": "object" + }, + "DeleteProvider": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "password" + ], + "type": "object" + }, + "DeleteService": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "password" + ], + "type": "object" + }, + "DeleteSubConversationRequest": { + "description": "Delete an MLS subconversation", + "properties": { + "epoch": { + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + } + }, + "required": [ + "group_id", + "epoch" + ], + "type": "object" + }, + "DeleteUser": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "DeletionCodeTimeout": { + "properties": { + "expires_in": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "required": [ + "expires_in" + ], + "type": "object" + }, + "DeprecatedMatchingResult": { + "deprecated": true, + "properties": { + "auto-connects": { + "items": {}, + "type": "array" + }, + "results": { + "items": {}, + "type": "array" + } + }, + "required": [ + "results", + "auto-connects" + ], + "type": "object" + }, + "DigitalSignaturesConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "DisableLegalHoldForUserRequest": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "Domain": { + "example": "example.com", + "type": "string" + }, + "EdMemberLeftReason": { + "enum": [ + "left", + "user-deleted", + "removed" + ], + "type": "string" + }, + "Either_OAuthAccessTokenRequest_OAuthRefreshAccessTokenRequest": { + "oneOf": [ + { + "properties": { + "Left": { + "$ref": "#/components/schemas/OAuthAccessTokenRequest" + } + }, + "required": [ + "Left" + ], + "title": "Left", + "type": "object" + }, + { + "properties": { + "Right": { + "$ref": "#/components/schemas/OAuthRefreshAccessTokenRequest" + } + }, + "required": [ + "Right" + ], + "title": "Right", + "type": "object" + } + ] + }, + "Email": { + "type": "string" + }, + "EmailUpdate": { + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "EnforceFileDownloadLocation": { + "properties": { + "enforcedDownloadLocation": { + "type": "string" + } + }, + "type": "object" + }, + "EnforceFileDownloadLocation.Feature": { + "properties": { + "config": { + "$ref": "#/components/schemas/EnforceFileDownloadLocation" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "config" + ], + "type": "object" + }, + "EnforceFileDownloadLocation.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/EnforceFileDownloadLocation" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "Epoch Timestamp": { + "example": "2021-05-12T10:52:02Z", + "format": "yyyy-mm-ddThh:MM:ssZ", + "type": "string" + }, + "Event": { + "properties": { + "conversation": { + "$ref": "#/components/schemas/UUID" + }, + "data": { + "description": "Encrypted message of a conversation", + "example": "ZXhhbXBsZQo=", + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "$ref": "#/components/schemas/AccessRoleLegacy" + }, + "access_role_v2": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "code": { + "$ref": "#/components/schemas/ASCII" + }, + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + }, + "creator": { + "$ref": "#/components/schemas/UUID" + }, + "data": { + "description": "Extra (symmetric) data (i.e. ciphertext, Base64 in JSON) that is common with all other recipients.", + "type": "string" + }, + "email": { + "type": "string" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/components/schemas/Epoch Timestamp" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + }, + "has_password": { + "description": "Whether the conversation has a password", + "type": "boolean" + }, + "hidden": { + "type": "boolean" + }, + "hidden_ref": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "key": { + "$ref": "#/components/schemas/ASCII" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/components/schemas/ConvMembers" + }, + "message": { + "type": "string" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "name": { + "type": "string" + }, + "otr_archived": { + "type": "boolean" + }, + "otr_archived_ref": { + "type": "string" + }, + "otr_muted_ref": { + "type": "string" + }, + "otr_muted_status": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "protocol": { + "$ref": "#/components/schemas/Protocol" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "qualified_recipient": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "qualified_target": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "qualified_user_ids": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "type": "array" + }, + "reason": { + "$ref": "#/components/schemas/EdMemberLeftReason" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "recipient": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "sender": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/TypingStatus" + }, + "target": { + "$ref": "#/components/schemas/UUID" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "text": { + "description": "The ciphertext for the recipient (Base64 in JSON)", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/ConvType" + }, + "uri": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "user_ids": { + "deprecated": true, + "description": "Deprecated, use qualified_user_ids", + "items": { + "$ref": "#/components/schemas/UUID" + }, + "type": "array" + }, + "users": { + "items": { + "$ref": "#/components/schemas/SimpleMember" + }, + "type": "array" + } + }, + "required": [ + "users", + "reason", + "qualified_user_ids", + "user_ids", + "qualified_target", + "name", + "access", + "key", + "code", + "has_password", + "qualified_id", + "type", + "members", + "group_id", + "epoch", + "epoch_timestamp", + "cipher_suite", + "qualified_recipient", + "receipt_mode", + "sender", + "recipient", + "text", + "status" + ], + "type": "object" + }, + "from": { + "$ref": "#/components/schemas/UUID" + }, + "qualified_conversation": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "qualified_from": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "subconv": { + "type": "string" + }, + "time": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "type": { + "$ref": "#/components/schemas/EventType" + } + }, + "required": [ + "type", + "data", + "qualified_conversation", + "qualified_from", + "time" + ], + "type": "object" + }, + "EventType": { + "enum": [ + "conversation.member-join", + "conversation.member-leave", + "conversation.member-update", + "conversation.rename", + "conversation.access-update", + "conversation.receipt-mode-update", + "conversation.message-timer-update", + "conversation.code-update", + "conversation.code-delete", + "conversation.create", + "conversation.delete", + "conversation.connect-request", + "conversation.typing", + "conversation.otr-message-add", + "conversation.mls-message-add", + "conversation.mls-welcome", + "conversation.protocol-update" + ], + "type": "string" + }, + "ExposeInvitationURLsToTeamAdminConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "ExposeInvitationURLsToTeamAdminConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "FeatureStatus": { + "enum": [ + "enabled", + "disabled" + ], + "type": "string" + }, + "FederatedUserSearchPolicy": { + "description": "Search policy that was applied when searching for users", + "enum": [ + "no_search", + "exact_handle_search", + "full_search" + ], + "type": "string" + }, + "FileSharingConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "FileSharingConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "Fingerprint": { + "example": "ioy3GeIjgQRsobf2EKGO3O8mq/FofFxHRqy0T4ERIZ8=", + "type": "string" + }, + "FormRedirect": { + "properties": { + "uri": { + "type": "string" + }, + "xml": { + "$ref": "#/components/schemas/AuthnRequest" + } + }, + "type": "object" + }, + "GetPaginated_Connections": { + "description": "A request to list some or all of a user's Connections, including remote ones", + "properties": { + "paging_state": { + "$ref": "#/components/schemas/Connections_PagingState" + }, + "size": { + "description": "optional, must be <= 500, defaults to 100.", + "format": "int32", + "maximum": 500, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "GetPaginated_ConversationIds": { + "description": "A request to list some or all of a user's ConversationIds, including remote ones", + "properties": { + "paging_state": { + "$ref": "#/components/schemas/ConversationIds_PagingState" + }, + "size": { + "description": "optional, must be <= 1000, defaults to 1000.", + "format": "int32", + "maximum": 1000, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "GroupId": { + "description": "A base64-encoded MLS group ID", + "example": "ZXhhbXBsZQo=", + "type": "string" + }, + "GroupInfoData": { + "description": "This object can only be parsed in TLS format. Please refer to the MLS specification for details." + }, + "GuestLinksConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "GuestLinksConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "Handle": { + "type": "string" + }, + "HandleUpdate": { + "properties": { + "handle": { + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" + }, + "HttpsUrl": { + "example": "https://example.com", + "type": "string" + }, + "ID_*_AuthnRequest": { + "properties": { + "iD": { + "$ref": "#/components/schemas/XmlText" + } + }, + "required": [ + "iD" + ], + "type": "object" + }, + "Icon": { + "type": "string" + }, + "Id": { + "properties": { + "id": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "IdPConfig_WireIdP": { + "properties": { + "extraInfo": { + "$ref": "#/components/schemas/WireIdP" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "metadata": { + "$ref": "#/components/schemas/IdPMetadata" + } + }, + "required": [ + "id", + "metadata", + "extraInfo" + ], + "type": "object" + }, + "IdPList": { + "properties": { + "providers": { + "items": { + "$ref": "#/components/schemas/IdPConfig_WireIdP" + }, + "type": "array" + } + }, + "required": [ + "providers" + ], + "type": "object" + }, + "IdPMetadata": { + "properties": { + "certAuthnResponse": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "issuer": { + "type": "string" + }, + "requestURI": { + "type": "string" + } + }, + "required": [ + "issuer", + "requestURI", + "certAuthnResponse" + ], + "type": "object" + }, + "IdPMetadataInfo": { + "maxProperties": 1, + "minProperties": 1, + "properties": { + "value": { + "type": "string" + } + }, + "type": "object" + }, + "Invitation": { + "description": "An invitation to join a team on Wire", + "properties": { + "created_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "created_by": { + "$ref": "#/components/schemas/UUID" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "description": "Name of the invitee (1 - 128 characters)", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "url": { + "$ref": "#/components/schemas/URIRef Absolute" + } + }, + "required": [ + "team", + "id", + "created_at", + "email" + ], + "type": "object" + }, + "InvitationList": { + "description": "A list of sent team invitations.", + "properties": { + "has_more": { + "description": "Indicator that the server has more invitations than returned.", + "type": "boolean" + }, + "invitations": { + "items": { + "$ref": "#/components/schemas/Invitation" + }, + "type": "array" + } + }, + "required": [ + "invitations", + "has_more" + ], + "type": "object" + }, + "InvitationRequest": { + "description": "A request to join a team on Wire.", + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + }, + "locale": { + "$ref": "#/components/schemas/Locale" + }, + "name": { + "description": "Name of the invitee (1 - 128 characters).", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/Role" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "InviteQualified": { + "properties": { + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + }, + "qualified_users": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "qualified_users" + ], + "type": "object" + }, + "JoinConversationByCode": { + "description": "Request body for joining a conversation by code", + "properties": { + "code": { + "$ref": "#/components/schemas/ASCII" + }, + "key": { + "$ref": "#/components/schemas/ASCII" + }, + "password": { + "maxLength": 1024, + "minLength": 8, + "type": "string" + }, + "uri": { + "$ref": "#/components/schemas/HttpsUrl" + } + }, + "required": [ + "key", + "code" + ], + "type": "object" + }, + "KeyPackage": { + "example": "a2V5IHBhY2thZ2UgZGF0YQo=", + "type": "string" + }, + "KeyPackageBundle": { + "properties": { + "key_packages": { + "items": { + "$ref": "#/components/schemas/KeyPackageBundleEntry" + }, + "type": "array" + } + }, + "required": [ + "key_packages" + ], + "type": "object" + }, + "KeyPackageBundleEntry": { + "properties": { + "client": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "domain": { + "$ref": "#/components/schemas/Domain" + }, + "key_package": { + "$ref": "#/components/schemas/KeyPackage" + }, + "key_package_ref": { + "$ref": "#/components/schemas/KeyPackageRef" + }, + "user": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "domain", + "user", + "client", + "key_package_ref", + "key_package" + ], + "type": "object" + }, + "KeyPackageRef": { + "example": "ZXhhbXBsZQo=", + "type": "string" + }, + "KeyPackageUpload": { + "properties": { + "key_packages": { + "items": { + "$ref": "#/components/schemas/KeyPackage" + }, + "type": "array" + } + }, + "required": [ + "key_packages" + ], + "type": "object" + }, + "LHServiceStatus": { + "enum": [ + "configured", + "not_configured", + "disabled" + ], + "type": "string" + }, + "LegalholdConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "LegalholdConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "LimitedEventFanoutConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "LimitedQualifiedUserIdList_500": { + "properties": { + "qualified_users": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "type": "array" + } + }, + "required": [ + "qualified_users" + ], + "type": "object" + }, + "List1": { + "items": { + "$ref": "#/components/schemas/ASCII" + }, + "minItems": 1, + "type": "array" + }, + "ListConversations": { + "description": "A request to list some of a user's conversations, including remote ones. Maximum 1000 qualified conversation IDs", + "properties": { + "qualified_ids": { + "items": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "maxItems": 1000, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "qualified_ids" + ], + "type": "object" + }, + "ListType": { + "description": "true if 'members' doesn't contain all team members", + "enum": [ + true, + false + ], + "type": "boolean" + }, + "ListUsersById": { + "properties": { + "failed": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "minItems": 1, + "type": "array" + }, + "found": { + "items": { + "$ref": "#/components/schemas/UserProfile" + }, + "type": "array" + } + }, + "required": [ + "found" + ], + "type": "object" + }, + "ListUsersQuery": { + "description": "exactly one of qualified_ids or qualified_handles must be provided.", + "example": { + "qualified_ids": [ + { + "domain": "example.com", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "properties": { + "qualified_handles": { + "items": { + "$ref": "#/components/schemas/Qualified_Handle" + }, + "type": "array" + }, + "qualified_ids": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "type": "array" + } + }, + "type": "object" + }, + "Locale": { + "type": "string" + }, + "LocaleUpdate": { + "properties": { + "locale": { + "$ref": "#/components/schemas/Locale" + } + }, + "required": [ + "locale" + ], + "type": "object" + }, + "LockStatus": { + "enum": [ + "locked", + "unlocked" + ], + "type": "string" + }, + "Login": { + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + }, + "handle": { + "$ref": "#/components/schemas/Handle" + }, + "label": { + "type": "string" + }, + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "verification_code": { + "$ref": "#/components/schemas/ASCII" + } + }, + "required": [ + "password" + ], + "type": "object" + }, + "MLSConfig": { + "properties": { + "allowedCipherSuites": { + "items": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "type": "array" + }, + "defaultCipherSuite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "defaultProtocol": { + "$ref": "#/components/schemas/Protocol" + }, + "protocolToggleUsers": { + "description": "allowlist of users that may change protocols", + "items": { + "$ref": "#/components/schemas/UUID" + }, + "type": "array" + }, + "supportedProtocols": { + "items": { + "$ref": "#/components/schemas/Protocol" + }, + "type": "array" + } + }, + "required": [ + "protocolToggleUsers", + "defaultProtocol", + "allowedCipherSuites", + "defaultCipherSuite", + "supportedProtocols" + ], + "type": "object" + }, + "MLSConfig.Feature": { + "properties": { + "config": { + "$ref": "#/components/schemas/MLSConfig" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "config" + ], + "type": "object" + }, + "MLSConfig.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/MLSConfig" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "MLSKeys": { + "properties": { + "ecdsa_secp256r1_sha256": { + "$ref": "#/components/schemas/SomeKey" + }, + "ecdsa_secp384r1_sha384": { + "$ref": "#/components/schemas/SomeKey" + }, + "ecdsa_secp521r1_sha512": { + "$ref": "#/components/schemas/SomeKey" + }, + "ed25519": { + "$ref": "#/components/schemas/SomeKey" + } + }, + "required": [ + "ed25519", + "ecdsa_secp256r1_sha256", + "ecdsa_secp384r1_sha384", + "ecdsa_secp521r1_sha512" + ], + "type": "object" + }, + "MLSKeysByPurpose": { + "properties": { + "removal": { + "$ref": "#/components/schemas/MLSKeys" + } + }, + "required": [ + "removal" + ], + "type": "object" + }, + "MLSMessage": { + "description": "This object can only be parsed in TLS format. Please refer to the MLS specification for details." + }, + "MLSMessageSendingStatus": { + "properties": { + "events": { + "description": "A list of events caused by sending the message.", + "items": { + "$ref": "#/components/schemas/Event" + }, + "type": "array" + }, + "time": { + "$ref": "#/components/schemas/UTCTimeMillis" + } + }, + "required": [ + "events", + "time" + ], + "type": "object" + }, + "MLSOne2OneConversation_MLSPublicKey": { + "properties": { + "conversation": { + "$ref": "#/components/schemas/Conversation" + }, + "public_keys": { + "$ref": "#/components/schemas/MLSKeysByPurpose" + } + }, + "required": [ + "conversation", + "public_keys" + ], + "type": "object" + }, + "MLSPublicKey": { + "example": "ZXhhbXBsZQo=", + "type": "string" + }, + "MLSPublicKeys": { + "additionalProperties": { + "example": "ZXhhbXBsZQo=", + "type": "string" + }, + "description": "Mapping from signature scheme (tags) to public key data", + "example": { + "ecdsa_secp256r1_sha256": "ZXhhbXBsZQo=", + "ecdsa_secp384r1_sha384": "ZXhhbXBsZQo=", + "ecdsa_secp521r1_sha512": "ZXhhbXBsZQo=", + "ed25519": "ZXhhbXBsZQo=" + }, + "type": "object" + }, + "ManagedBy": { + "enum": [ + "wire", + "scim" + ], + "type": "string" + }, + "Member": { + "description": "The user ID of the requestor", + "properties": { + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + }, + "hidden": { + "type": "boolean" + }, + "hidden_ref": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "otr_archived": { + "type": "boolean" + }, + "otr_archived_ref": { + "type": "string" + }, + "otr_muted_ref": { + "type": "string" + }, + "otr_muted_status": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "service": { + "$ref": "#/components/schemas/ServiceRef" + }, + "status": {}, + "status_ref": {}, + "status_time": {} + }, + "required": [ + "qualified_id" + ], + "type": "object" + }, + "MemberUpdate": { + "properties": { + "hidden": { + "type": "boolean" + }, + "hidden_ref": { + "type": "string" + }, + "otr_archived": { + "type": "boolean" + }, + "otr_archived_ref": { + "type": "string" + }, + "otr_muted_ref": { + "type": "string" + }, + "otr_muted_status": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "type": "object" + }, + "MemberUpdateData": { + "properties": { + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + }, + "hidden": { + "type": "boolean" + }, + "hidden_ref": { + "type": "string" + }, + "otr_archived": { + "type": "boolean" + }, + "otr_archived_ref": { + "type": "string" + }, + "otr_muted_ref": { + "type": "string" + }, + "otr_muted_status": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "qualified_target": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "target": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "qualified_target" + ], + "type": "object" + }, + "MessageSendingStatus": { + "description": "The Proteus message sending status. It has these fields:\n- `time`: Time of sending message.\n- `missing`: Clients that the message /should/ have been encrypted for, but wasn't.\n- `redundant`: Clients that the message /should not/ have been encrypted for, but was.\n- `deleted`: Clients that were deleted.\n- `failed_to_send`: When message sending fails for some clients but succeeds for others, e.g., because a remote backend is unreachable, this field will contain the list of clients for which the message sending failed. This list should be empty when message sending is not even tried, like when some clients are missing.", + "properties": { + "deleted": { + "$ref": "#/components/schemas/QualifiedUserClients" + }, + "failed_to_confirm_clients": { + "$ref": "#/components/schemas/QualifiedUserClients" + }, + "failed_to_send": { + "$ref": "#/components/schemas/QualifiedUserClients" + }, + "missing": { + "$ref": "#/components/schemas/QualifiedUserClients" + }, + "redundant": { + "$ref": "#/components/schemas/QualifiedUserClients" + }, + "time": { + "$ref": "#/components/schemas/UTCTimeMillis" + } + }, + "required": [ + "time", + "missing", + "redundant", + "deleted", + "failed_to_send", + "failed_to_confirm_clients" + ], + "type": "object" + }, + "MlsE2EIdConfig": { + "properties": { + "acmeDiscoveryUrl": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "crlProxy": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "useProxyOnMobile": { + "type": "boolean" + }, + "verificationExpiration": { + "description": "When a client first tries to fetch or renew a certificate, they may need to login to an identity provider (IdP) depending on their IdP domain authentication policy. The user may have a grace period during which they can \"snooze\" this login. The duration of this grace period (in seconds) is set in the `verificationDuration` parameter, which is enforced separately by each client. After the grace period has expired, the client will not allow the user to use the application until they have logged to refresh the certificate. The default value is 1 day (86400s). The client enrolls using the Automatic Certificate Management Environment (ACME) protocol. The `acmeDiscoveryUrl` parameter must be set to the HTTPS URL of the ACME server discovery endpoint for this team. It is of the form \"https://acme.{backendDomain}/acme/{provisionerName}/discovery\". For example: `https://acme.example.com/acme/provisioner1/discovery`.", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + } + }, + "required": [ + "verificationExpiration" + ], + "type": "object" + }, + "MlsE2EIdConfig.Feature": { + "properties": { + "config": { + "$ref": "#/components/schemas/MlsE2EIdConfig" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "config" + ], + "type": "object" + }, + "MlsE2EIdConfig.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/MlsE2EIdConfig" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "MlsMigration": { + "properties": { + "finaliseRegardlessAfter": { + "$ref": "#/components/schemas/UTCTime" + }, + "startTime": { + "$ref": "#/components/schemas/UTCTime" + } + }, + "type": "object" + }, + "MlsMigration.Feature": { + "properties": { + "config": { + "$ref": "#/components/schemas/MlsMigration" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "config" + ], + "type": "object" + }, + "MlsMigration.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/MlsMigration" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "NameIDFormat": { + "enum": [ + "NameIDFUnspecified", + "NameIDFEmail", + "NameIDFX509", + "NameIDFWindows", + "NameIDFKerberos", + "NameIDFEntity", + "NameIDFPersistent", + "NameIDFTransient" + ], + "type": "string" + }, + "NameIdPolicy": { + "properties": { + "allowCreate": { + "type": "boolean" + }, + "format": { + "$ref": "#/components/schemas/NameIDFormat" + }, + "spNameQualifier": { + "$ref": "#/components/schemas/XmlText" + } + }, + "required": [ + "format", + "allowCreate" + ], + "type": "object" + }, + "NewAssetToken": { + "properties": { + "token": { + "$ref": "#/components/schemas/ASCII" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + "NewClient": { + "properties": { + "capabilities": { + "description": "Hints provided by the client for the backend so it can behave in a backwards-compatible way.", + "items": { + "$ref": "#/components/schemas/ClientCapability" + }, + "type": "array" + }, + "class": { + "$ref": "#/components/schemas/ClientClass" + }, + "cookie": { + "description": "The cookie label, i.e. the label used when logging in.", + "type": "string" + }, + "label": { + "type": "string" + }, + "lastkey": { + "$ref": "#/components/schemas/Prekey" + }, + "mls_public_keys": { + "$ref": "#/components/schemas/MLSPublicKeys" + }, + "model": { + "type": "string" + }, + "password": { + "description": "The password of the authenticated user for verification. Note: Required for registration of the 2nd, 3rd, ... client.", + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "prekeys": { + "description": "Prekeys for other clients to establish OTR sessions.", + "items": { + "$ref": "#/components/schemas/Prekey" + }, + "type": "array" + }, + "type": { + "$ref": "#/components/schemas/ClientType" + }, + "verification_code": { + "$ref": "#/components/schemas/ASCII" + } + }, + "required": [ + "prekeys", + "lastkey", + "type" + ], + "type": "object" + }, + "NewConv": { + "description": "JSON object to create a new conversation. When using 'qualified_users' (preferred), you can omit 'users'", + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + }, + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + }, + "message_timer": { + "description": "Per-conversation message timer", + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "name": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "protocol": { + "$ref": "#/components/schemas/BaseProtocol" + }, + "qualified_users": { + "description": "List of qualified user IDs (excluding the requestor) to be part of this conversation", + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "type": "array" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/components/schemas/ConvTeamInfo" + }, + "users": { + "deprecated": true, + "description": "List of user IDs (excluding the requestor) to be part of this conversation (deprecated)", + "items": { + "$ref": "#/components/schemas/UUID" + }, + "type": "array" + } + }, + "type": "object" + }, + "NewLegalHoldService": { + "properties": { + "auth_token": { + "$ref": "#/components/schemas/ASCII" + }, + "base_url": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "public_key": { + "$ref": "#/components/schemas/ServiceKeyPEM" + } + }, + "required": [ + "base_url", + "public_key", + "auth_token" + ], + "type": "object" + }, + "NewPasswordReset": { + "description": "Data to initiate a password reset", + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + }, + "phone": { + "description": "Email", + "type": "string" + } + }, + "type": "object" + }, + "NewProvider": { + "properties": { + "description": { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "url": { + "$ref": "#/components/schemas/HttpsUrl" + } + }, + "required": [ + "name", + "email", + "url", + "description" + ], + "type": "object" + }, + "NewProviderResponse": { + "properties": { + "id": { + "$ref": "#/components/schemas/UUID" + }, + "password": { + "maxLength": 1024, + "minLength": 8, + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "NewService": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "auth_token": { + "$ref": "#/components/schemas/ASCII" + }, + "base_url": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "description": { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "public_key": { + "$ref": "#/components/schemas/ServiceKeyPEM" + }, + "summary": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "tags": { + "items": { + "$ref": "#/components/schemas/" + }, + "maxItems": 3, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "name", + "summary", + "description", + "base_url", + "public_key", + "assets", + "tags" + ], + "type": "object" + }, + "NewServiceResponse": { + "properties": { + "auth_token": { + "$ref": "#/components/schemas/ASCII" + }, + "id": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "NewTeamMember": { + "description": "Required data when creating new team members", + "properties": { + "member": { + "description": "the team member to add (the legalhold_status field must be null or missing!)", + "properties": { + "created_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "created_by": { + "$ref": "#/components/schemas/UUID" + }, + "permissions": { + "$ref": "#/components/schemas/Permissions" + }, + "user": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "user", + "permissions" + ], + "type": "object" + } + }, + "required": [ + "member" + ], + "type": "object" + }, + "NewUser": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "email_code": { + "$ref": "#/components/schemas/ASCII" + }, + "expires_in": { + "maximum": 604800, + "minimum": 1, + "type": "integer" + }, + "invitation_code": { + "$ref": "#/components/schemas/ASCII" + }, + "label": { + "type": "string" + }, + "locale": { + "$ref": "#/components/schemas/Locale" + }, + "managed_by": { + "$ref": "#/components/schemas/ManagedBy" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "password": { + "maxLength": 1024, + "minLength": 8, + "type": "string" + }, + "picture": { + "$ref": "#/components/schemas/Pict" + }, + "sso_id": { + "$ref": "#/components/schemas/UserSSOId" + }, + "supported_protocols": { + "items": { + "$ref": "#/components/schemas/BaseProtocol" + }, + "type": "array" + }, + "team": { + "$ref": "#/components/schemas/BindingNewTeamUser" + }, + "team_code": { + "$ref": "#/components/schemas/ASCII" + }, + "team_id": { + "$ref": "#/components/schemas/UUID" + }, + "uuid": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "OAuthAccessTokenRequest": { + "properties": { + "client_id": { + "$ref": "#/components/schemas/UUID" + }, + "code": { + "$ref": "#/components/schemas/OAuthAuthorizationCode" + }, + "code_verifier": { + "description": "The code verifier to complete the code challenge", + "maxLength": 128, + "minLength": 43, + "type": "string" + }, + "grant_type": { + "$ref": "#/components/schemas/OAuthGrantType" + }, + "redirect_uri": { + "$ref": "#/components/schemas/RedirectUrl" + } + }, + "required": [ + "grant_type", + "client_id", + "code_verifier", + "code", + "redirect_uri" + ], + "type": "object" + }, + "OAuthAccessTokenResponse": { + "properties": { + "access_token": { + "description": "The access token, which has a relatively short lifetime", + "type": "string" + }, + "expires_in": { + "description": "The lifetime of the access token in seconds", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "refresh_token": { + "description": "The refresh token, which has a relatively long lifetime, and can be used to obtain a new access token", + "type": "string" + }, + "token_type": { + "$ref": "#/components/schemas/OAuthAccessTokenType" + } + }, + "required": [ + "access_token", + "token_type", + "expires_in", + "refresh_token" + ], + "type": "object" + }, + "OAuthAccessTokenType": { + "description": "The type of the access token. Currently only `Bearer` is supported.", + "enum": [ + "Bearer" + ], + "type": "string" + }, + "OAuthApplication": { + "properties": { + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "description": "The OAuth client's name", + "maxLength": 256, + "minLength": 6, + "type": "string" + }, + "sessions": { + "description": "The OAuth client's sessions", + "items": { + "$ref": "#/components/schemas/OAuthSession" + }, + "type": "array" + } + }, + "required": [ + "id", + "name", + "sessions" + ], + "type": "object" + }, + "OAuthAuthorizationCode": { + "description": "The authorization code", + "type": "string" + }, + "OAuthClient": { + "properties": { + "application_name": { + "maxLength": 256, + "minLength": 6, + "type": "string" + }, + "client_id": { + "$ref": "#/components/schemas/UUID" + }, + "redirect_url": { + "$ref": "#/components/schemas/RedirectUrl" + } + }, + "required": [ + "client_id", + "application_name", + "redirect_url" + ], + "type": "object" + }, + "OAuthCodeChallenge": { + "description": "Generated by the client from the code verifier (unpadded base64url-encoded SHA256 hash of the code verifier)", + "type": "string" + }, + "OAuthGrantType": { + "description": "Indicates which authorization flow to use. Use `authorization_code` for authorization code flow.", + "enum": [ + "authorization_code", + "refresh_token" + ], + "type": "string" + }, + "OAuthRefreshAccessTokenRequest": { + "properties": { + "client_id": { + "$ref": "#/components/schemas/UUID" + }, + "grant_type": { + "$ref": "#/components/schemas/OAuthGrantType" + }, + "refresh_token": { + "description": "The refresh token", + "type": "string" + } + }, + "required": [ + "grant_type", + "client_id", + "refresh_token" + ], + "type": "object" + }, + "OAuthResponseType": { + "description": "Indicates which authorization flow to use. Use `code` for authorization code flow.", + "enum": [ + "code" + ], + "type": "string" + }, + "OAuthRevokeRefreshTokenRequest": { + "properties": { + "client_id": { + "$ref": "#/components/schemas/UUID" + }, + "refresh_token": { + "description": "The refresh token", + "type": "string" + } + }, + "required": [ + "client_id", + "refresh_token" + ], + "type": "object" + }, + "OAuthSession": { + "properties": { + "created_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "refresh_token_id": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "refresh_token_id", + "created_at" + ], + "type": "object" + }, + "Object": { + "additionalProperties": true, + "description": "A single notification event", + "properties": { + "type": { + "description": "Event type", + "type": "string" + } + }, + "title": "Event", + "type": "object" + }, + "OtherMember": { + "properties": { + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "service": { + "$ref": "#/components/schemas/ServiceRef" + }, + "status": { + "deprecated": true, + "description": "deprecated", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + } + }, + "required": [ + "qualified_id" + ], + "type": "object" + }, + "OtherMemberUpdate": { + "description": "Update user properties of other members relative to a conversation", + "properties": { + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + } + }, + "type": "object" + }, + "OtrMessage": { + "description": "Encrypted message of a conversation", + "properties": { + "data": { + "description": "Extra (symmetric) data (i.e. ciphertext, Base64 in JSON) that is common with all other recipients.", + "type": "string" + }, + "recipient": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "sender": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "text": { + "description": "The ciphertext for the recipient (Base64 in JSON)", + "type": "string" + } + }, + "required": [ + "sender", + "recipient", + "text" + ], + "type": "object" + }, + "OutlookCalIntegrationConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "OutlookCalIntegrationConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "OwnKeyPackages": { + "properties": { + "count": { + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + } + }, + "required": [ + "count" + ], + "type": "object" + }, + "PagingState": { + "description": "Paging state that should be supplied to retrieve the next page of results", + "type": "string" + }, + "PasswordChange": { + "properties": { + "new_password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "old_password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "old_password", + "new_password" + ], + "type": "object" + }, + "PasswordReqBody": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "PasswordReset": { + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "Permissions": { + "description": "This is just a complicated way of representing a team role. self and copy always have to contain the same integer, and only the following integers are allowed: 1025 (partner), 1587 (member), 5951 (admin), 8191 (owner). Unit tests of the galley-types package in wire-server contain an authoritative list.", + "properties": { + "copy": { + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "self": { + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "self", + "copy" + ], + "type": "object" + }, + "PhoneNumber": { + "description": "A known phone number with a pending password reset.", + "type": "string" + }, + "Pict": { + "items": { + "type": "object" + }, + "maxItems": 10, + "minItems": 0, + "type": "array" + }, + "Prekey": { + "properties": { + "id": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "key": { + "type": "string" + } + }, + "required": [ + "id", + "key" + ], + "type": "object" + }, + "PrekeyBundle": { + "properties": { + "clients": { + "items": { + "$ref": "#/components/schemas/ClientPrekey" + }, + "type": "array" + }, + "user": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "user", + "clients" + ], + "type": "object" + }, + "Priority": { + "enum": [ + "low", + "high" + ], + "type": "string" + }, + "PropertyKeysAndValues": { + "type": "object" + }, + "PropertyValue": { + "description": "An arbitrary JSON value for a property" + }, + "Protocol": { + "enum": [ + "proteus", + "mls", + "mixed" + ], + "type": "string" + }, + "ProtocolUpdate": { + "properties": { + "protocol": { + "$ref": "#/components/schemas/Protocol" + } + }, + "type": "object" + }, + "Provider": { + "properties": { + "description": { + "type": "string" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "url": { + "$ref": "#/components/schemas/HttpsUrl" + } + }, + "required": [ + "id", + "name", + "email", + "url", + "description" + ], + "type": "object" + }, + "ProviderActivationResponse": { + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "ProviderLogin": { + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + }, + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "email", + "password" + ], + "type": "object" + }, + "PubClient": { + "properties": { + "class": { + "$ref": "#/components/schemas/ClientClass" + }, + "id": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "PublicSubConversation": { + "description": "An MLS subconversation", + "properties": { + "cipher_suite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/components/schemas/UTCTime" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + }, + "members": { + "items": { + "$ref": "#/components/schemas/ClientIdentity" + }, + "type": "array" + }, + "parent_qualified_id": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "subconv_id": { + "type": "string" + } + }, + "required": [ + "parent_qualified_id", + "subconv_id", + "group_id", + "epoch", + "members" + ], + "type": "object" + }, + "PushToken": { + "description": "Native Push Token", + "properties": { + "app": { + "description": "Application", + "type": "string" + }, + "client": { + "description": "Client ID", + "type": "string" + }, + "token": { + "description": "Access Token", + "type": "string" + }, + "transport": { + "$ref": "#/components/schemas/Transport" + } + }, + "required": [ + "transport", + "app", + "token", + "client" + ], + "type": "object" + }, + "PushTokenList": { + "description": "List of Native Push Tokens", + "properties": { + "tokens": { + "description": "Push tokens", + "items": { + "$ref": "#/components/schemas/PushToken" + }, + "type": "array" + } + }, + "required": [ + "tokens" + ], + "type": "object" + }, + "QualifiedNewOtrMessage": { + "description": "This object can only be parsed from Protobuf.\nThe specification for the protobuf types is here: \nhttps://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto." + }, + "QualifiedUserClientPrekeyMapV4": { + "properties": { + "failed_to_list": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "type": "array" + }, + "qualified_user_client_prekeys": { + "additionalProperties": { + "$ref": "#/components/schemas/UserClientPrekeyMap" + }, + "type": "object" + } + }, + "required": [ + "qualified_user_client_prekeys" + ], + "type": "object" + }, + "QualifiedUserClients": { + "additionalProperties": { + "additionalProperties": { + "items": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + "description": "Map of Domain to UserClients", + "example": { + "domain1.example.com": { + "1d51e2d6-9c70-605f-efc8-ff85c3dabdc7": [ + "60f85e4b15ad3786", + "6e323ab31554353b" + ] + } + }, + "type": "object" + }, + "QualifiedUserIdList with EdMemberLeftReason": { + "properties": { + "qualified_user_ids": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "type": "array" + }, + "reason": { + "$ref": "#/components/schemas/EdMemberLeftReason" + }, + "user_ids": { + "deprecated": true, + "description": "Deprecated, use qualified_user_ids", + "items": { + "$ref": "#/components/schemas/UUID" + }, + "type": "array" + } + }, + "required": [ + "reason", + "qualified_user_ids", + "user_ids" + ], + "type": "object" + }, + "QualifiedUserMap_Set_PubClient": { + "additionalProperties": { + "$ref": "#/components/schemas/UserMap_Set_PubClient" + }, + "description": "Map of Domain to (UserMap (Set_PubClient)).", + "example": { + "domain1.example.com": { + "1d51e2d6-9c70-605f-efc8-ff85c3dabdc7": [ + { + "class": "legalhold", + "id": "d0" + } + ] + } + }, + "type": "object" + }, + "Qualified_ConvId": { + "properties": { + "domain": { + "$ref": "#/components/schemas/Domain" + }, + "id": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "domain", + "id" + ], + "type": "object" + }, + "Qualified_Handle": { + "properties": { + "domain": { + "$ref": "#/components/schemas/Domain" + }, + "handle": { + "$ref": "#/components/schemas/Handle" + } + }, + "required": [ + "domain", + "handle" + ], + "type": "object" + }, + "Qualified_UserId": { + "properties": { + "domain": { + "$ref": "#/components/schemas/Domain" + }, + "id": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "domain", + "id" + ], + "type": "object" + }, + "QueuedNotification": { + "description": "A single notification", + "properties": { + "id": { + "$ref": "#/components/schemas/UUID" + }, + "payload": { + "description": "List of events", + "items": { + "$ref": "#/components/schemas/Object" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "id", + "payload" + ], + "type": "object" + }, + "QueuedNotificationList": { + "description": "Zero or more notifications", + "properties": { + "has_more": { + "description": "Whether there are still more notifications.", + "type": "boolean" + }, + "notifications": { + "description": "Notifications", + "items": { + "$ref": "#/components/schemas/QueuedNotification" + }, + "type": "array" + }, + "time": { + "$ref": "#/components/schemas/UTCTime" + } + }, + "required": [ + "notifications" + ], + "type": "object" + }, + "RTCConfiguration": { + "description": "A subset of the WebRTC 'RTCConfiguration' dictionary", + "properties": { + "ice_servers": { + "description": "Array of 'RTCIceServer' objects", + "items": { + "$ref": "#/components/schemas/RTCIceServer" + }, + "minItems": 1, + "type": "array" + }, + "is_federating": { + "description": "True if the client should connect to an SFT in the sft_servers_all and request it to federate", + "type": "boolean" + }, + "sft_servers": { + "description": "Array of 'SFTServer' objects (optional)", + "items": { + "$ref": "#/components/schemas/SftServer" + }, + "minItems": 1, + "type": "array" + }, + "sft_servers_all": { + "description": "Array of all SFT servers", + "items": { + "$ref": "#/components/schemas/SftServer" + }, + "type": "array" + }, + "ttl": { + "description": "Number of seconds after which the configuration should be refreshed (advisory)", + "format": "int32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "ice_servers", + "ttl" + ], + "type": "object" + }, + "RTCIceServer": { + "description": "A subset of the WebRTC 'RTCIceServer' object", + "properties": { + "credential": { + "$ref": "#/components/schemas/ASCII" + }, + "urls": { + "description": "Array of TURN server addresses of the form 'turn::'", + "items": { + "$ref": "#/components/schemas/TurnURI" + }, + "minItems": 1, + "type": "array" + }, + "username": { + "$ref": "#/components/schemas/" + } + }, + "required": [ + "urls", + "username", + "credential" + ], + "type": "object" + }, + "RedirectUrl": { + "description": "The URL must match the URL that was used to generate the authorization code.", + "type": "string" + }, + "Relation": { + "enum": [ + "accepted", + "blocked", + "pending", + "ignored", + "sent", + "cancelled", + "missing-legalhold-consent" + ], + "type": "string" + }, + "RemoveBotResponse": { + "properties": { + "event": { + "$ref": "#/components/schemas/Event" + } + }, + "required": [ + "event" + ], + "type": "object" + }, + "RemoveCookies": { + "description": "Data required to remove cookies", + "properties": { + "ids": { + "description": "A list of cookie IDs to revoke", + "items": { + "format": "int32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "type": "array" + }, + "labels": { + "description": "A list of cookie labels for which to revoke the cookies", + "items": { + "type": "string" + }, + "type": "array" + }, + "password": { + "description": "The user's password", + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "password" + ], + "type": "object" + }, + "RemoveLegalHoldSettingsRequest": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "RichField": { + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + "RichInfoAssocList": { + "description": "json object with case-insensitive fields.", + "properties": { + "fields": { + "items": { + "$ref": "#/components/schemas/RichField" + }, + "type": "array" + }, + "version": { + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + } + }, + "required": [ + "version", + "fields" + ], + "type": "object" + }, + "Role": { + "description": "Role of the invited user", + "enum": [ + "owner", + "admin", + "member", + "partner" + ], + "type": "string" + }, + "RoleName": { + "description": "Role name, between 2 and 128 chars, 'wire_' prefix is reserved for roles designed by Wire (i.e., no custom roles can have the same prefix)", + "type": "string" + }, + "SSOConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "ScimTokenInfo": { + "properties": { + "created_at": { + "$ref": "#/components/schemas/UTCTime" + }, + "description": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "idp": { + "$ref": "#/components/schemas/UUID" + }, + "team": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "team", + "id", + "created_at", + "description" + ], + "type": "object" + }, + "ScimTokenList": { + "properties": { + "tokens": { + "items": { + "$ref": "#/components/schemas/ScimTokenInfo" + }, + "type": "array" + } + }, + "required": [ + "tokens" + ], + "type": "object" + }, + "SearchResult": { + "properties": { + "documents": { + "description": "List of contacts found", + "items": { + "$ref": "#/components/schemas/TeamContact" + }, + "type": "array" + }, + "found": { + "description": "Total number of hits", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "has_more": { + "description": "Indicates whether there are more results to be fetched", + "type": "boolean" + }, + "paging_state": { + "$ref": "#/components/schemas/PagingState" + }, + "returned": { + "description": "Total number of hits returned", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "search_policy": { + "$ref": "#/components/schemas/FederatedUserSearchPolicy" + }, + "took": { + "description": "Search time in ms", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + } + }, + "required": [ + "found", + "returned", + "took", + "documents", + "search_policy" + ], + "type": "object" + }, + "SearchVisibilityAvailableConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "SearchVisibilityAvailableConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "SearchVisibilityInboundConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "SearchVisibilityInboundConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "SelfDeletingMessagesConfig": { + "properties": { + "enforcedTimeoutSeconds": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "required": [ + "enforcedTimeoutSeconds" + ], + "type": "object" + }, + "SelfDeletingMessagesConfig.Feature": { + "properties": { + "config": { + "$ref": "#/components/schemas/SelfDeletingMessagesConfig" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "config" + ], + "type": "object" + }, + "SelfDeletingMessagesConfig.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/SelfDeletingMessagesConfig" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "SendActivationCode": { + "description": "Data for requesting an email code to be sent. 'email' must be present.", + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + }, + "locale": { + "$ref": "#/components/schemas/Locale" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "SendVerificationCode": { + "properties": { + "action": { + "$ref": "#/components/schemas/VerificationAction" + }, + "email": { + "$ref": "#/components/schemas/Email" + } + }, + "required": [ + "action", + "email" + ], + "type": "object" + }, + "Service": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "auth_tokens": { + "$ref": "#/components/schemas/List1" + }, + "base_url": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "public_keys": { + "$ref": "#/components/schemas/List1" + }, + "summary": { + "type": "string" + }, + "tags": { + "items": { + "$ref": "#/components/schemas/" + }, + "type": "array" + } + }, + "required": [ + "id", + "name", + "summary", + "description", + "base_url", + "auth_tokens", + "public_keys", + "assets", + "tags", + "enabled" + ], + "type": "object" + }, + "ServiceKey": { + "properties": { + "pem": { + "$ref": "#/components/schemas/ServiceKeyPEM" + }, + "size": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "type": { + "$ref": "#/components/schemas/ServiceKeyType" + } + }, + "required": [ + "type", + "size", + "pem" + ], + "type": "object" + }, + "ServiceKeyPEM": { + "example": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu+Kg/PHHU3atXrUbKnw0\nG06FliXcNt3lMwl2os5twEDcPPFw/feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPH\nWvUBdiLfGrZqJO223DB6D8K2Su/odmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKV\nVPOaOzgtAB21XKRiQ4ermqgi3/njr03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiS\nbUKr/BeArYRcjzr/h5m1In6fG/if9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg8\n7X883H+LA/d6X5CTiPv1VMxXdBUiGPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7la\nnQIDAQAB\n-----END PUBLIC KEY-----\n", + "type": "string" + }, + "ServiceKeyType": { + "enum": [ + "rsa" + ], + "type": "string" + }, + "ServiceProfile": { + "properties": { + "has_more": { + "type": "boolean" + }, + "services": { + "items": { + "$ref": "#/components/schemas/ServiceProfile" + }, + "type": "array" + } + }, + "required": [ + "has_more", + "services" + ], + "type": "object" + }, + "ServiceRef": { + "properties": { + "id": { + "$ref": "#/components/schemas/UUID" + }, + "provider": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "id", + "provider" + ], + "type": "object" + }, + "ServiceTagList": { + "items": { + "$ref": "#/components/schemas/" + }, + "type": "array" + }, + "SftServer": { + "description": "Inspired by WebRTC 'RTCIceServer' object, contains details of SFT servers", + "properties": { + "urls": { + "description": "Array containing exactly one SFT server address of the form 'https://:'", + "items": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "type": "array" + } + }, + "required": [ + "urls" + ], + "type": "object" + }, + "SimpleMember": { + "properties": { + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_UserId" + } + }, + "required": [ + "qualified_id" + ], + "type": "object" + }, + "SimpleMembers": { + "properties": { + "user_ids": { + "deprecated": true, + "description": "deprecated", + "items": { + "$ref": "#/components/schemas/UUID" + }, + "type": "array" + }, + "users": { + "items": { + "$ref": "#/components/schemas/SimpleMember" + }, + "type": "array" + } + }, + "required": [ + "users" + ], + "type": "object" + }, + "SndFactorPasswordChallengeConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "SndFactorPasswordChallengeConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "SomeKey": {}, + "Sso": { + "properties": { + "issuer": { + "type": "string" + }, + "nameid": { + "type": "string" + } + }, + "required": [ + "issuer", + "nameid" + ], + "type": "object" + }, + "SsoSettings": { + "properties": { + "default_sso_code": { + "$ref": "#/components/schemas/UUID" + } + }, + "type": "object" + }, + "SupportedProtocolUpdate": { + "properties": { + "supported_protocols": { + "items": { + "$ref": "#/components/schemas/BaseProtocol" + }, + "type": "array" + } + }, + "required": [ + "supported_protocols" + ], + "type": "object" + }, + "SystemSettings": { + "properties": { + "setEnableMls": { + "description": "Whether MLS is enabled or not", + "type": "boolean" + }, + "setRestrictUserCreation": { + "description": "Do not allow certain user creation flows", + "type": "boolean" + } + }, + "required": [ + "setRestrictUserCreation", + "setEnableMls" + ], + "type": "object" + }, + "SystemSettingsPublic": { + "properties": { + "setRestrictUserCreation": { + "description": "Do not allow certain user creation flows", + "type": "boolean" + } + }, + "required": [ + "setRestrictUserCreation" + ], + "type": "object" + }, + "Team": { + "description": "`binding` is deprecated, and should be ignored. The non-binding teams API is not used (and will not be supported from API version V4 onwards), and `binding` will always be `true`.", + "properties": { + "binding": { + "$ref": "#/components/schemas/TeamBinding" + }, + "creator": { + "$ref": "#/components/schemas/UUID" + }, + "icon": { + "$ref": "#/components/schemas/Icon" + }, + "icon_key": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "type": "string" + }, + "splash_screen": { + "$ref": "#/components/schemas/Icon" + } + }, + "required": [ + "id", + "creator", + "name", + "icon" + ], + "type": "object" + }, + "TeamBinding": { + "deprecated": true, + "description": "Deprecated, please ignore.", + "enum": [ + true, + false + ], + "type": "boolean" + }, + "TeamContact": { + "properties": { + "accent_id": { + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "created_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "email_unvalidated": { + "$ref": "#/components/schemas/Email" + }, + "handle": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "managed_by": { + "$ref": "#/components/schemas/ManagedBy" + }, + "name": { + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "saml_idp": { + "type": "string" + }, + "scim_external_id": { + "type": "string" + }, + "sso": { + "$ref": "#/components/schemas/Sso" + }, + "team": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "TeamConversation": { + "description": "Team conversation data", + "properties": { + "conversation": { + "$ref": "#/components/schemas/UUID" + }, + "managed": { + "description": "This field MUST NOT be used by clients. It is here only for backwards compatibility of the interface." + } + }, + "required": [ + "conversation", + "managed" + ], + "type": "object" + }, + "TeamConversationList": { + "description": "Team conversation list", + "properties": { + "conversations": { + "items": { + "$ref": "#/components/schemas/TeamConversation" + }, + "type": "array" + } + }, + "required": [ + "conversations" + ], + "type": "object" + }, + "TeamDeleteData": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "verification_code": { + "$ref": "#/components/schemas/ASCII" + } + }, + "type": "object" + }, + "TeamMember": { + "description": "team member data", + "properties": { + "created_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "created_by": { + "$ref": "#/components/schemas/UUID" + }, + "legalhold_status": { + "$ref": "#/components/schemas/UserLegalHoldStatus" + }, + "permissions": { + "$ref": "#/components/schemas/Permissions" + }, + "user": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "user" + ], + "type": "object" + }, + "TeamMemberDeleteData": { + "description": "Data for a team member deletion request in case of binding teams.", + "properties": { + "password": { + "description": "The account password to authorise the deletion.", + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "TeamMemberList": { + "description": "list of team member", + "properties": { + "hasMore": { + "$ref": "#/components/schemas/ListType" + }, + "members": { + "description": "the array of team members", + "items": { + "$ref": "#/components/schemas/TeamMember" + }, + "type": "array" + } + }, + "required": [ + "members", + "hasMore" + ], + "type": "object" + }, + "TeamMembersPage": { + "properties": { + "hasMore": { + "type": "boolean" + }, + "members": { + "items": { + "$ref": "#/components/schemas/TeamMember" + }, + "type": "array" + }, + "pagingState": { + "$ref": "#/components/schemas/TeamMembers_PagingState" + } + }, + "required": [ + "members", + "hasMore", + "pagingState" + ], + "type": "object" + }, + "TeamMembers_PagingState": { + "type": "string" + }, + "TeamSearchVisibility": { + "description": "value of visibility", + "enum": [ + "standard", + "no-name-outside-team" + ], + "type": "string" + }, + "TeamSearchVisibilityView": { + "description": "Search visibility value for the team", + "properties": { + "search_visibility": { + "$ref": "#/components/schemas/TeamSearchVisibility" + } + }, + "required": [ + "search_visibility" + ], + "type": "object" + }, + "TeamSize": { + "description": "A simple object with a total number of team members.", + "properties": { + "teamSize": { + "description": "Team size.", + "exclusiveMinimum": false, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "teamSize" + ], + "type": "object" + }, + "TeamUpdateData": { + "properties": { + "icon": { + "$ref": "#/components/schemas/Icon" + }, + "icon_key": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "splash_screen": { + "$ref": "#/components/schemas/Icon" + } + }, + "type": "object" + }, + "Time": { + "properties": { + "time": { + "$ref": "#/components/schemas/UTCTime" + } + }, + "required": [ + "time" + ], + "type": "object" + }, + "TokenType": { + "enum": [ + "Bearer" + ], + "type": "string" + }, + "Transport": { + "description": "Transport", + "enum": [ + "GCM", + "APNS", + "APNS_SANDBOX", + "APNS_VOIP", + "APNS_VOIP_SANDBOX" + ], + "type": "string" + }, + "TurnURI": { + "type": "string" + }, + "TypingData": { + "properties": { + "status": { + "$ref": "#/components/schemas/TypingStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "TypingStatus": { + "enum": [ + "started", + "stopped" + ], + "type": "string" + }, + "URIRef Absolute": { + "description": "URL of the invitation link to be sent to the invitee", + "type": "string" + }, + "UTCTime": { + "example": "2021-05-12T10:52:02Z", + "format": "yyyy-mm-ddThh:MM:ssZ", + "type": "string" + }, + "UTCTimeMillis": { + "description": "The time when the session was created", + "example": "2021-05-12T10:52:02.671Z", + "format": "yyyy-mm-ddThh:MM:ss.qqqZ", + "type": "string" + }, + "UUID": { + "description": "The OAuth client's ID", + "example": "99db9768-04e3-4b5d-9268-831b6a25c4ab", + "format": "uuid", + "type": "string" + }, + "Unnamed": { + "properties": { + "created_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "created_by": { + "$ref": "#/components/schemas/UUID" + }, + "permissions": { + "$ref": "#/components/schemas/Permissions" + }, + "user": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "user", + "permissions" + ], + "type": "object" + }, + "UpdateBotPrekeys": { + "properties": { + "prekeys": { + "items": { + "$ref": "#/components/schemas/Prekey" + }, + "type": "array" + } + }, + "required": [ + "prekeys" + ], + "type": "object" + }, + "UpdateClient": { + "properties": { + "capabilities": { + "description": "Hints provided by the client for the backend so it can behave in a backwards-compatible way.", + "items": { + "$ref": "#/components/schemas/ClientCapability" + }, + "type": "array" + }, + "label": { + "description": "A new name for this client.", + "type": "string" + }, + "lastkey": { + "$ref": "#/components/schemas/Prekey" + }, + "mls_public_keys": { + "$ref": "#/components/schemas/MLSPublicKeys" + }, + "prekeys": { + "description": "New prekeys for other clients to establish OTR sessions.", + "items": { + "$ref": "#/components/schemas/Prekey" + }, + "type": "array" + } + }, + "type": "object" + }, + "UpdateProvider": { + "properties": { + "description": { + "type": "string" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "url": { + "$ref": "#/components/schemas/HttpsUrl" + } + }, + "type": "object" + }, + "UpdateService": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "description": { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "summary": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "tags": { + "items": { + "$ref": "#/components/schemas/" + }, + "maxItems": 3, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + }, + "UpdateServiceConn": { + "properties": { + "auth_tokens": { + "items": { + "$ref": "#/components/schemas/ASCII" + }, + "maxItems": 2, + "minItems": 1, + "type": "array" + }, + "base_url": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "enabled": { + "type": "boolean" + }, + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "public_keys": { + "items": { + "$ref": "#/components/schemas/ServiceKeyPEM" + }, + "maxItems": 2, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "password" + ], + "type": "object" + }, + "UpdateServiceWhitelist": { + "properties": { + "id": { + "$ref": "#/components/schemas/UUID" + }, + "provider": { + "$ref": "#/components/schemas/UUID" + }, + "whitelisted": { + "type": "boolean" + } + }, + "required": [ + "provider", + "id", + "whitelisted" + ], + "type": "object" + }, + "User": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "expires_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "handle": { + "$ref": "#/components/schemas/Handle" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "locale": { + "$ref": "#/components/schemas/Locale" + }, + "managed_by": { + "$ref": "#/components/schemas/ManagedBy" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "picture": { + "$ref": "#/components/schemas/Pict" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "service": { + "$ref": "#/components/schemas/ServiceRef" + }, + "sso_id": { + "$ref": "#/components/schemas/UserSSOId" + }, + "supported_protocols": { + "items": { + "$ref": "#/components/schemas/BaseProtocol" + }, + "type": "array" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "text_status": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "qualified_id", + "name", + "accent_id", + "locale" + ], + "type": "object" + }, + "UserAsset": { + "properties": { + "key": { + "$ref": "#/components/schemas/AssetKey" + }, + "size": { + "$ref": "#/components/schemas/AssetSize" + }, + "type": { + "$ref": "#/components/schemas/AssetType" + } + }, + "required": [ + "key", + "type" + ], + "type": "object" + }, + "UserClientMap": { + "additionalProperties": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": "object" + }, + "UserClientPrekeyMap": { + "additionalProperties": { + "additionalProperties": { + "properties": { + "id": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "key": { + "type": "string" + } + }, + "required": [ + "id", + "key" + ], + "type": "object" + }, + "type": "object" + }, + "example": { + "1d51e2d6-9c70-605f-efc8-ff85c3dabdc7": { + "44901fb0712e588f": { + "id": 1, + "key": "pQABAQECoQBYIOjl7hw0D8YRNq..." + } + } + }, + "type": "object" + }, + "UserClients": { + "additionalProperties": { + "items": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "type": "array" + }, + "description": "Map of user id to list of client ids.", + "example": { + "1d51e2d6-9c70-605f-efc8-ff85c3dabdc7": [ + "60f85e4b15ad3786", + "6e323ab31554353b" + ] + }, + "type": "object" + }, + "UserConnection": { + "properties": { + "conversation": { + "$ref": "#/components/schemas/UUID" + }, + "from": { + "$ref": "#/components/schemas/UUID" + }, + "last_update": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "qualified_conversation": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "qualified_to": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "status": { + "$ref": "#/components/schemas/Relation" + }, + "to": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "from", + "qualified_to", + "status", + "last_update" + ], + "type": "object" + }, + "UserIdList": { + "properties": { + "user_ids": { + "items": { + "$ref": "#/components/schemas/UUID" + }, + "type": "array" + } + }, + "required": [ + "user_ids" + ], + "type": "object" + }, + "UserLegalHoldStatus": { + "description": "The state of Legal Hold compliance for the member", + "enum": [ + "enabled", + "pending", + "disabled", + "no_consent" + ], + "type": "string" + }, + "UserLegalHoldStatusResponse": { + "properties": { + "client": { + "$ref": "#/components/schemas/Id" + }, + "last_prekey": { + "$ref": "#/components/schemas/Prekey" + }, + "status": { + "$ref": "#/components/schemas/UserLegalHoldStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "UserMap_Set_PubClient": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/PubClient" + }, + "type": "array", + "uniqueItems": true + }, + "description": "Map of UserId to (Set PubClient)", + "example": { + "1d51e2d6-9c70-605f-efc8-ff85c3dabdc7": [ + { + "class": "legalhold", + "id": "d0" + } + ] + }, + "type": "object" + }, + "UserProfile": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "expires_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "handle": { + "$ref": "#/components/schemas/Handle" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "legalhold_status": { + "$ref": "#/components/schemas/UserLegalHoldStatus" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "picture": { + "$ref": "#/components/schemas/Pict" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "service": { + "$ref": "#/components/schemas/ServiceRef" + }, + "supported_protocols": { + "items": { + "$ref": "#/components/schemas/BaseProtocol" + }, + "type": "array" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "text_status": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "qualified_id", + "name", + "accent_id", + "legalhold_status" + ], + "type": "object" + }, + "UserSSOId": { + "properties": { + "scim_external_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "tenant": { + "type": "string" + } + }, + "type": "object" + }, + "UserUpdate": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "picture": { + "$ref": "#/components/schemas/Pict" + }, + "text_status": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "ValidateSAMLEmailsConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "VerificationAction": { + "enum": [ + "create_scim_token", + "login", + "delete_team" + ], + "type": "string" + }, + "VerifyDeleteUser": { + "description": "Data for verifying an account deletion.", + "properties": { + "code": { + "$ref": "#/components/schemas/ASCII" + }, + "key": { + "$ref": "#/components/schemas/ASCII" + } + }, + "required": [ + "key", + "code" + ], + "type": "object" + }, + "VersionInfo": { + "example": { + "development": [ + 7 + ], + "domain": "example.com", + "federation": false, + "supported": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ] + }, + "properties": { + "development": { + "items": { + "$ref": "#/components/schemas/VersionNumber" + }, + "type": "array" + }, + "domain": { + "$ref": "#/components/schemas/Domain" + }, + "federation": { + "type": "boolean" + }, + "supported": { + "items": { + "$ref": "#/components/schemas/VersionNumber" + }, + "type": "array" + } + }, + "required": [ + "supported", + "development", + "federation", + "domain" + ], + "type": "object" + }, + "VersionNumber": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ], + "type": "integer" + }, + "ViewLegalHoldService": { + "properties": { + "settings": { + "$ref": "#/components/schemas/ViewLegalHoldServiceInfo" + }, + "status": { + "$ref": "#/components/schemas/LHServiceStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "ViewLegalHoldServiceInfo": { + "properties": { + "auth_token": { + "$ref": "#/components/schemas/ASCII" + }, + "base_url": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "fingerprint": { + "$ref": "#/components/schemas/Fingerprint" + }, + "public_key": { + "$ref": "#/components/schemas/ServiceKeyPEM" + }, + "team_id": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "team_id", + "base_url", + "fingerprint", + "auth_token", + "public_key" + ], + "type": "object" + }, + "WireIdP": { + "properties": { + "apiVersion": { + "$ref": "#/components/schemas/WireIdPAPIVersion" + }, + "handle": { + "type": "string" + }, + "oldIssuers": { + "items": { + "type": "string" + }, + "type": "array" + }, + "replacedBy": { + "$ref": "#/components/schemas/UUID" + }, + "team": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "team", + "oldIssuers", + "handle" + ], + "type": "object" + }, + "WireIdPAPIVersion": { + "enum": [ + "WireIdPAPIV1", + "WireIdPAPIV2" + ], + "type": "string" + }, + "XmlText": { + "properties": { + "fromXmlText": { + "type": "string" + } + }, + "required": [ + "fromXmlText" + ], + "type": "object" + }, + "new-otr-message": { + "properties": { + "data": { + "type": "string" + }, + "native_priority": { + "$ref": "#/components/schemas/Priority" + }, + "native_push": { + "type": "boolean" + }, + "recipients": { + "$ref": "#/components/schemas/UserClientMap" + }, + "report_missing": { + "items": { + "$ref": "#/components/schemas/UUID" + }, + "type": "array" + }, + "sender": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "transient": { + "type": "boolean" + } + }, + "required": [ + "sender", + "recipients" + ], + "type": "object" + } + }, + "securitySchemes": { + "ZAuth": { + "description": "Must be a token retrieved by calling 'POST /login' or 'POST /access'. It must be presented in this format: 'Bearer \\'.", + "in": "header", + "name": "Authorization", + "type": "apiKey" + } + } + }, + "info": { + "description": "## Authentication / Authorization\n\nThe end-points in this API support differing authorization protocols:\nsome are unauthenticated (`/api-version`, `/login`), some require\n[zauth](), and some support both [zauth]() and [oauth]().\n\nThe end-points that require zauth are labelled so in the description\nbelow. The end-points that support oauth as an alternative to zauth\nhave the required oauth scopes listed in the same description.\n\nFuther reading:\n- https://docs.wire.com/developer/reference/oauth.html\n- https://github.com/wireapp/wire-server/blob/develop/libs/wire-api/src/Wire/API/Routes/Public.hs (search for HasSwagger instances)\n- `curl https://staging-nginz-https.zinfra.io/v4/api/swagger.json | jq '.security, .securityDefinitions`\n\n### SSO Endpoints\n\n#### Overview\n\n`/sso/metadata` will be requested by the IdPs to learn how to talk to wire.\n\n`/sso/initiate-login`, `/sso/finalize-login` are for the SAML authentication handshake performed by a user in order to log into wire. They are not exactly standard in their details: they may return HTML or XML; redirect to error URLs instead of throwing errors, etc.\n\n`/identity-providers` end-points are for use in the team settings page when IdPs are registered. They talk json.\n\n\n#### Configuring IdPs\n\nIdPs usually allow you to copy the metadata into your clipboard. That should contain all the details you need to post the idp in your team under `/identity-providers`. (Team id is derived from the authorization credentials of the request.)\n\n##### okta.com\n\nOkta will ask you to provide two URLs when you set it up for talking to wireapp:\n\n1. The `Single sign on URL`. This is the end-point that accepts the user's credentials after successful authentication against the IdP. Choose `/sso/finalize-login` with schema and hostname of the wire server you are configuring.\n\n2. The `Audience URI`. You can find this in the metadata returned by the `/sso/metadata` end-point. It is the contents of the `md:OrganizationURL` element.\n\n##### centrify.com\n\nCentrify allows you to upload the metadata xml document that you get from the `/sso/metadata` end-point. You can also enter the metadata url and have centrify retrieve the xml, but to guarantee integrity of the setup, the metadata should be copied from the team settings page and pasted into the centrify setup page without any URL indirections.\n\n## Federation errors\n\nEndpoints involving federated calls to other domains can return some extra failure responses, common to all endpoints. Instead of listing them as possible responses for each endpoint, we document them here.\n\nFor errors that are more likely to be transient, we suggest clients to retry whatever request resulted in the error. Transient errors are indicated explicitly below.\n\n**Note**: when a failure occurs as a result of making a federated RPC to another backend, the error response contains the following extra fields:\n\n - `type`: \"federation\" (just the literal string in quotes, which can be used as an error type identifier when parsing errors)\n - `domain`: the target backend of the RPC that failed;\n - `path`: the path of the RPC that failed.\n\n### Domain errors\n\nErrors in this category result from trying to communicate with a backend that is considered non-existent or invalid. They can result from invalid user input or client issues, but they can also be a symptom of misconfiguration in one or multiple backends. These errors have a 4xx status code.\n\n - **Remote backend not found** (status: 422, label: `invalid-domain`): This backend attempted to contact a backend which does not exist or is not properly configured. For the most part, clients can consider this error equivalent to a domain not existing, although it should be noted that certain mistakes in the DNS configuration on a remote backend can lead to the backend not being recognized, and hence to this error. It is therefore not advisable to take any destructive action upon encountering this error, such as deleting remote users from conversations.\n - **Federation denied locally** (status: 400, label: `federation-denied`): This backend attempted an RPC to a non-whitelisted backend. Similar considerations as for the previous error apply.\n - **Federation not enabled** (status: 400, label: `federation-not-enabled`): Federation has not been configured for this backend. This will happen if a federation-aware client tries to talk to a backend for which federation is disabled, or if federation was disabled on the backend after reaching a federation-specific state (e.g. conversations with remote users). There is no way to cleanly recover from these errors at this point.\n\n### Local federation errors\n\nAn error in this category likely indicates an issue with the configuration of federation on the local backend. Possibly transient errors are indicated explicitly below. All these errors have a 500 status code.\n\n - **Federation unavailable** (status: 500, label: `federation-not-available`): Federation is configured for this backend, but the local federator cannot be reached. This can be transient, so clients should retry the request.\n - **Federation not implemented** (status: 500, label: `federation-not-implemented`): Federated behaviour for a certain endpoint is not yet implemented.\n - **Federator discovery failed** (status: 400, label: `discovery-failure`): A DNS error occurred during discovery of a remote backend. This can be transient, so clients should retry the request.\n - **Local federation error** (status: 500, label: `federation-local-error`): An error occurred in the communication between this backend and its local federator. These errors are most likely caused by bugs in the backend, and should be reported as such.\n\n### Remote federation errors\n\nErrors in this category are returned in case of communication issues between the local backend and a remote one, or if the remote side encountered an error while processing an RPC. Some errors in this category might be caused by incorrect client behaviour, wrong user input, or incorrect certificate configuration. Possibly transient errors are indicated explicitly. We use non-standard 5xx status codes for these errors.\n\n - **HTTP2 error** (status: 533, label: `federation-http2-error`): The current federator encountered an error when making an HTTP2 request to a remote one. Check the error message for more details.\n - **Connection refused** (status: 521, label: `federation-connection-refused`): The local federator could not connect to a remote one. This could be transient, so clients should retry the request.\n - **TLS failure**: (status: 525, label: `federation-tls-error`): An error occurred during the TLS handshake between the local federator and a remote one. This is most likely due to an issue with the certificate on the remote end.\n - **Remote federation error** (status: 533, label: `federation-remote-error`): The remote backend could not process a request coming from this backend. Check the error message for more details.\n - **Version negotiation error** (status: 533, label: `federation-version-error`): The remote backend returned invalid version information.\n\n### Backend compatibility errors\n\nAn error in this category will be returned when this backend makes an invalid or unsupported RPC to another backend. This can indicate some incompatibility between backends or a backend bug. These errors are unlikely to be transient, so retrying requests is *not* advised.\n\n - **Version mismatch** (status: 531, label: `federation-version-mismatch`): A remote backend is running an unsupported version of the federator.\n - **Invalid content type** (status: 533, label: `federation-invalid-content-type`): An RPC to another backend returned with an invalid content type.\n - **Unsupported content type** (status: 533, label: `federation-unsupported-content-type`): An RPC to another backend returned with an unsupported content type.\n", + "title": "Wire-Server API", + "version": "" + }, + "openapi": "3.0.0", + "paths": { + "/": { + "get": { + "description": " [internal route ID: \"get-services-tags\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ServiceTagList" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "Get services tags" + } + }, + "/access": { + "post": { + "description": " [internal route ID: \"access\"]\n\nYou can provide only a cookie or a cookie and token. Every other combination is invalid. Access tokens can be given as query parameter or authorisation header, with the latter being preferred.Calls federation service brig on send-connection-action", + "parameters": [ + { + "in": "query", + "name": "client_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessToken" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AccessToken" + } + } + }, + "description": "OK", + "headers": { + "Set-Cookie": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)" + } + }, + "summary": "Obtain an access tokens for a cookie" + } + }, + "/access/logout": { + "post": { + "description": " [internal route ID: \"logout\"]\n\nCalling this endpoint will effectively revoke the given cookie and subsequent calls to /access with the same cookie will result in a 403.", + "responses": { + "200": { + "description": "Logout" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)" + } + }, + "summary": "Log out in order to remove a cookie from the server" + } + }, + "/access/self/email": { + "put": { + "description": " [internal route ID: \"change-self-email\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/EmailUpdate" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + } + }, + "description": "Update accepted and pending activation of the new email" + }, + "204": { + "content": { + "application/json": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + } + }, + "description": "No update, current and new email address are the same" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-email", + "message": "Invalid e-mail address." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid e-mail address. (label: `invalid-email`) or `body`" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "blacklisted-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The given e-mail address is in use. (label: `key-exists`)" + } + }, + "summary": "Change your email address" + } + }, + "/activate": { + "get": { + "description": " [internal route ID: \"get-activate\"]\n\nSee also 'POST /activate' which has a larger feature set.
Calls federation service brig on send-connection-action", + "parameters": [ + { + "description": "Activation key", + "in": "query", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Activation code", + "in": "query", + "name": "code", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivationResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ActivationResponse" + } + } + }, + "description": "Activation successful.\n\nActivation successful. (Dry run)\n\nActivation successful." + }, + "204": { + "description": "A recent activation was already successful." + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-phone", + "message": "Invalid mobile phone number" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-phone", + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `code` or `key`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "invalid-code", + "message": "Invalid activation code" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The given e-mail address is in use. (label: `key-exists`)" + } + }, + "summary": "Activate (i.e. confirm) an email address." + }, + "post": { + "description": " [internal route ID: \"post-activate\"]\n\nActivation only succeeds once and the number of failed attempts for a valid key is limited.Calls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Activate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivationResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ActivationResponse" + } + } + }, + "description": "Activation successful.\n\nActivation successful. (Dry run)\n\nActivation successful." + }, + "204": { + "description": "A recent activation was already successful." + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-phone", + "message": "Invalid mobile phone number" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-phone", + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "invalid-code", + "message": "Invalid activation code" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The given e-mail address is in use. (label: `key-exists`)" + } + }, + "summary": "Activate (i.e. confirm) an email address." + } + }, + "/activate/send": { + "post": { + "description": " [internal route ID: \"post-activate-send\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SendActivationCode" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Activation code sent." + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-email", + "message": "Invalid e-mail address." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "blacklisted-email", + "message": "The given e-mail address has been blacklisted due to a permanent bounce or a complaint." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "blacklisted-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The given e-mail address is in use. (label: `key-exists`)" + }, + "451": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 451, + "label": "domain-blocked-for-registration", + "message": "[Customer extension] the email domain example.com that you are attempting to register a user with has been blocked for creating wire users. Please contact your IT department." + }, + "properties": { + "code": { + "enum": [ + 451 + ], + "type": "integer" + }, + "label": { + "enum": [ + "domain-blocked-for-registration" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "[Customer extension] the email domain example.com that you are attempting to register a user with has been blocked for creating wire users. Please contact your IT department. (label: `domain-blocked-for-registration`)" + } + }, + "summary": "Send (or resend) an email activation code." + } + }, + "/api-version": { + "get": { + "description": " [internal route ID: \"get-version\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/VersionInfo" + } + } + }, + "description": "" + } + } + } + }, + "/assets": { + "post": { + "requestBody": { + "content": { + "multipart/mixed": { + "schema": { + "$ref": "#/components/schemas/AssetSource" + } + } + }, + "description": "A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server." + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + } + }, + "description": "Asset posted", + "headers": { + "Location": { + "description": "Asset location", + "schema": { + "format": "url", + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-length", + "message": "Invalid content length" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-length" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid content length (label: `invalid-length`)" + }, + "413": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 413, + "label": "client-error", + "message": "Asset too large" + }, + "properties": { + "code": { + "enum": [ + 413 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Asset too large (label: `client-error`)" + } + }, + "summary": "Upload an asset" + } + }, + "/assets/{key_domain}/{key}": { + "delete": { + "description": "**Note**: only local assets can be deleted.", + "parameters": [ + { + "in": "path", + "name": "key_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Asset deleted" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unauthorised", + "message": "Unauthorised operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorised" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unauthorised operation (label: `unauthorised`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`key_domain` or `key` not found\n\nAsset not found (label: `not-found`)" + } + }, + "summary": "Delete an asset" + }, + "get": { + "description": "**Note**: local assets result in a redirect, while remote assets are streamed directly.Calls federation service cargohold on stream-asset
Calls federation service cargohold on get-asset", + "parameters": [ + { + "in": "path", + "name": "key_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Asset-Token", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "asset_token", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Asset returned directly with content type `application/octet-stream`" + }, + "302": { + "description": "Asset found", + "headers": { + "Location": { + "description": "Asset location", + "schema": { + "format": "url", + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`key_domain` or `key` or Asset not found (label: `not-found`)\n\nAsset not found (label: `not-found`)" + } + }, + "summary": "Download an asset" + } + }, + "/assets/{key}/token": { + "delete": { + "description": "**Note**: deleting the token makes the asset public.", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Asset token deleted" + } + }, + "summary": "Delete an asset token" + }, + "post": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewAssetToken" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unauthorised", + "message": "Unauthorised operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorised" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unauthorised operation (label: `unauthorised`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`key` not found\n\nAsset not found (label: `not-found`)" + } + }, + "summary": "Renew an asset token" + } + }, + "/await": { + "get": { + "description": " [internal route ID: \"await-notifications\"]\n\n", + "externalDocs": { + "description": "RFC 6455", + "url": "https://datatracker.ietf.org/doc/html/rfc6455" + }, + "parameters": [ + { + "description": "Client ID", + "in": "query", + "name": "client", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "101": { + "description": "Connection upgraded." + }, + "426": { + "description": "Upgrade required." + } + }, + "summary": "Establish websocket connection" + } + }, + "/bot/assets": { + "post": { + "requestBody": { + "content": { + "multipart/mixed": { + "schema": { + "$ref": "#/components/schemas/AssetSource" + } + } + }, + "description": "A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server." + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + } + }, + "description": "Asset posted", + "headers": { + "Location": { + "description": "Asset location", + "schema": { + "format": "url", + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-length", + "message": "Invalid content length" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-length" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid content length (label: `invalid-length`)" + }, + "413": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 413, + "label": "client-error", + "message": "Asset too large" + }, + "properties": { + "code": { + "enum": [ + 413 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Asset too large (label: `client-error`)" + } + }, + "summary": "Upload an asset" + } + }, + "/bot/assets/{key}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Asset deleted" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unauthorised", + "message": "Unauthorised operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorised" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unauthorised operation (label: `unauthorised`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`key` not found\n\nAsset not found (label: `not-found`)" + } + }, + "summary": "Delete an asset" + }, + "get": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Asset-Token", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "asset_token", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Asset found", + "headers": { + "Location": { + "description": "Asset location", + "schema": { + "format": "url", + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`key` or Asset not found (label: `not-found`)" + } + }, + "summary": "Download an asset" + } + }, + "/bot/client": { + "get": { + "description": " [internal route ID: \"bot-get-client-v6\"]\n\n", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Clientv6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Clientv6" + } + } + }, + "description": "Client found" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "client-not-found", + "message": "Client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "client-not-found", + "message": "Client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Client not found (label: `client-not-found`)\n\nClient not found (label: `client-not-found`)" + } + }, + "summary": "Get client for bot" + } + }, + "/bot/client/prekeys": { + "get": { + "description": " [internal route ID: \"bot-list-prekeys\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "items": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "type": "array" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "List prekeys for bot" + }, + "post": { + "description": " [internal route ID: \"bot-update-prekeys\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UpdateBotPrekeys" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "client-not-found", + "message": "Client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Client not found (label: `client-not-found`)" + } + }, + "summary": "Update prekeys for bot" + } + }, + "/bot/conversation": { + "get": { + "description": " [internal route ID: \"get-bot-conversation\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/BotConvView" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)\n\nYou do not have permission to access this resource (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team", + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Team not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)" + } + } + } + }, + "/bot/messages": { + "post": { + "description": " [internal route ID: \"post-bot-message-unqualified\"]\n\nCalls federation service brig on get-user-clients
Calls federation service galley on on-message-sent", + "parameters": [ + { + "in": "query", + "name": "ignore_missing", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "report_missing", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/new-otr-message" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + } + }, + "description": "Message sent" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation not found (label: `no-conversation`)\n\nConversation not found (label: `no-conversation`)" + }, + "412": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + } + }, + "description": "Missing clients" + } + } + } + }, + "/bot/self": { + "delete": { + "description": " [internal route ID: \"bot-delete-self\"]\n\n", + "responses": { + "200": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-bot", + "message": "The targeted user is not a bot." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-bot", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The targeted user is not a bot. (label: `invalid-bot`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Delete self" + }, + "get": { + "description": " [internal route ID: \"bot-get-self\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserProfile" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "User not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "User not found (label: `not-found`)" + } + }, + "summary": "Get self" + } + }, + "/bot/users": { + "get": { + "description": " [internal route ID: \"bot-list-users\"]\n\n", + "parameters": [ + { + "in": "query", + "name": "ids", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/BotUserView" + }, + "type": "array" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "List users" + } + }, + "/bot/users/prekeys": { + "post": { + "description": " [internal route ID: \"bot-claim-users-prekeys\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserClients" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserClientPrekeyMap" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "missing-legalhold-consent", + "message": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "missing-legalhold-consent", + "missing-legalhold-consent-old-clients", + "too-many-clients", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nToo many clients (label: `too-many-clients`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Claim users prekeys" + } + }, + "/bot/users/{User ID}/clients": { + "get": { + "description": " [internal route ID: \"bot-get-user-clients\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "User ID", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/PubClient" + }, + "type": "array" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "Get user clients" + } + }, + "/broadcast/otr/messages": { + "post": { + "description": " [internal route ID: \"post-otr-broadcast-unqualified\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts two query params:\n- `ignore_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are ignored.\n - When 'false' all missing clients are reported.\n - When comma separated list of user-ids, only clients for listed users are ignored.\n- `report_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are reported.\n - When 'false' all missing clients are ignored.\n - When comma separated list of user-ids, only clients for listed users are reported.\n\nApart from these, the request body also accepts `report_missing` which can only be a list of user ids and behaves the same way as the query parameter.\n\nAll three of these should be considered mutually exclusive. The server however does not error if more than one is specified, it reads them in this order of precedence:\n- `report_missing` in the request body has highest precedence.\n- `ignore_missing` in the query param is the next.\n- `report_missing` in the query param has the lowest precedence.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.", + "parameters": [ + { + "in": "query", + "name": "ignore_missing", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "report_missing", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/new-otr-message" + } + }, + "application/x-protobuf": { + "schema": { + "$ref": "#/components/schemas/new-otr-message" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + } + }, + "description": "Message sent" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "too-many-users-to-broadcast", + "message": "Too many users to fan out the broadcast event to" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-users-to-broadcast" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body` or `report_missing` or `ignore_missing`\n\nToo many users to fan out the broadcast event to (label: `too-many-users-to-broadcast`)" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "non-binding-team", + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation not found (label: `no-conversation`)\n\nNot a member of a binding team (label: `non-binding-team`)\n\nTeam not found (label: `no-team`)" + }, + "412": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + } + }, + "description": "Missing clients" + } + }, + "summary": "Broadcast an encrypted message to all team members and all contacts (accepts JSON or Protobuf)" + } + }, + "/broadcast/proteus/messages": { + "post": { + "description": " [internal route ID: \"post-proteus-broadcast\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts `client_mismatch_strategy` in the body. It can have these values:\n- `report_all`: When set, the message is not sent if any clients are missing. The missing clients are reported in the response.\n- `ignore_all`: When set, no checks about missing clients are carried out.\n- `report_only`: Takes a list of qualified UserIDs. If any clients of the listed users are missing, the message is not sent. The missing clients are reported in the response.\n- `ignore_only`: Takes a list of qualified UserIDs. If any clients of the non-listed users are missing, the message is not sent. The missing clients are reported in the response.\n\nThe sending of messages in a federated conversation could theoretically fail partially. To make this case unlikely, the backend first gets a list of clients from all the involved backends and then tries to send a message. So, if any backend is down, the message is not propagated to anyone. But the actual message fan out to multiple backends could still fail partially. This type of failure is reported as a 201, the clients for which the message sending failed are part of the response body.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.", + "requestBody": { + "content": { + "application/x-protobuf": { + "schema": { + "$ref": "#/components/schemas/QualifiedNewOtrMessage" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + } + }, + "description": "Message sent" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "too-many-users-to-broadcast", + "message": "Too many users to fan out the broadcast event to" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-users-to-broadcast" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nToo many users to fan out the broadcast event to (label: `too-many-users-to-broadcast`)" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "non-binding-team", + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation not found (label: `no-conversation`)\n\nNot a member of a binding team (label: `non-binding-team`)\n\nTeam not found (label: `no-team`)" + }, + "412": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + } + }, + "description": "Missing clients" + } + }, + "summary": "Post an encrypted message to all team members and all contacts (accepts only Protobuf)" + } + }, + "/calls/config": { + "get": { + "deprecated": true, + "description": " [internal route ID: \"get-calls-config\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/RTCConfiguration" + } + } + }, + "description": "" + } + }, + "summary": "Retrieve TURN server addresses and credentials for IP addresses, scheme `turn` and transport `udp` only (deprecated)" + } + }, + "/calls/config/v2": { + "get": { + "description": " [internal route ID: \"get-calls-config-v2\"]\n\n", + "parameters": [ + { + "description": "Limit resulting list. Allowed values [1..10]", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 10, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/RTCConfiguration" + } + } + }, + "description": "" + } + }, + "summary": "Retrieve all TURN server addresses and credentials. Clients are expected to do a DNS lookup to resolve the IP addresses of the given hostnames " + } + }, + "/clients": { + "get": { + "description": " [internal route ID: \"list-clients-v6\"]\n\n", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientListv6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientListv6" + } + } + }, + "description": "List of clients" + } + }, + "summary": "List the registered clients" + }, + "post": { + "description": " [internal route ID: \"add-client\"]\n\nCalls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewClient" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Client" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Client" + } + } + }, + "description": "Client registered", + "headers": { + "Location": { + "description": "Client ID", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "bad-request", + "message": "Malformed prekeys uploaded" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "bad-request" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nMalformed prekeys uploaded (label: `bad-request`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "missing-auth", + "too-many-clients" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nRe-authentication via password required (label: `missing-auth`)\n\nToo many clients (label: `too-many-clients`)" + } + }, + "summary": "Register a new client" + } + }, + "/clients/{cid}/access-token": { + "post": { + "description": " [internal route ID: \"create-access-token\"]\n\nCreate an JWT DPoP access token for the client CSR, given a JWT DPoP proof, specified in the `DPoP` header. The access token will be returned in the JSON response body as a JWT DPoP token.", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "cid", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "DPoP", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DPoPAccessTokenResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DPoPAccessTokenResponse" + } + } + }, + "description": "Access token created", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + } + } + } + }, + "summary": "Create a JWT DPoP access token" + } + }, + "/clients/{client}": { + "delete": { + "description": " [internal route ID: \"delete-client\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeleteClient" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Client deleted" + } + }, + "summary": "Delete an existing client" + }, + "get": { + "description": " [internal route ID: \"get-client-v6\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Clientv6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Clientv6" + } + } + }, + "description": "Client found" + }, + "404": { + "description": "`client` or Client not found(**Note**: This error has an empty body for legacy reasons)" + } + }, + "summary": "Get a registered client by ID" + }, + "put": { + "description": " [internal route ID: \"update-client\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UpdateClient" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Client updated" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "bad-request", + "message": "Malformed prekeys uploaded" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "bad-request" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nMalformed prekeys uploaded (label: `bad-request`)" + } + }, + "summary": "Update a registered client" + } + }, + "/clients/{client}/capabilities": { + "get": { + "description": " [internal route ID: \"get-client-capabilities\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientCapabilityList" + } + } + }, + "description": "" + } + }, + "summary": "Read back what the client has been posting about itself" + } + }, + "/clients/{client}/nonce": { + "get": { + "description": " [internal route ID: \"get-nonce\"]\n\nGet a new nonce for a client CSR, specified in the response header `Replay-Nonce` as a uuidv4 in base64url encoding.", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "Replay-Nonce": { + "schema": { + "type": "string" + } + } + } + } + }, + "summary": "Get a new nonce for a client CSR" + }, + "head": { + "description": " [internal route ID: \"head-nonce\"]\n\nGet a new nonce for a client CSR, specified in the response header `Replay-Nonce` as a uuidv4 in base64url encoding.", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "No Content", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "Replay-Nonce": { + "schema": { + "type": "string" + } + } + } + } + }, + "summary": "Get a new nonce for a client CSR" + } + }, + "/clients/{client}/prekeys": { + "get": { + "description": " [internal route ID: \"get-client-prekeys\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "items": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "summary": "List the remaining prekey IDs of a client" + } + }, + "/connections/{uid_domain}/{uid}": { + "get": { + "description": " [internal route ID: \"get-connection\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + } + }, + "description": "Connection found" + }, + "404": { + "description": "`uid_domain` or `uid` or Connection not found(**Note**: This error has an empty body for legacy reasons)" + } + }, + "summary": "Get an existing connection to another user (local or remote)" + }, + "post": { + "description": " [internal route ID: \"create-connection\"]\n\nYou can have no more than 1000 connections in accepted or sent state
Calls federation service brig on send-connection-action
Calls federation service brig on get-users-by-ids", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + } + }, + "description": "Connection existed" + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + } + }, + "description": "Connection was created" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-user", + "message": "Invalid user" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-user" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid user (label: `invalid-user`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-identity", + "message": "The user has no verified email" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-identity", + "connection-limit", + "missing-legalhold-consent", + "missing-legalhold-consent-old-clients" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The user has no verified email (label: `no-identity`)\n\nToo many sent/accepted connections (label: `connection-limit`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)" + } + }, + "summary": "Create a connection to another user" + }, + "put": { + "description": " [internal route ID: \"update-connection\"]\n\nCalls federation service brig on send-connection-action
Calls federation service brig on get-users-by-ids", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConnectionUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + } + }, + "description": "Connection updated" + }, + "204": { + "description": "Connection unchanged" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-user", + "message": "Invalid user" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-user" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid user (label: `invalid-user`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-identity", + "message": "The user has no verified email" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-identity", + "bad-conn-update", + "not-connected", + "connection-limit", + "missing-legalhold-consent", + "missing-legalhold-consent-old-clients" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The user has no verified email (label: `no-identity`)\n\nInvalid status transition (label: `bad-conn-update`)\n\nUsers are not connected (label: `not-connected`)\n\nToo many sent/accepted connections (label: `connection-limit`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)" + } + }, + "summary": "Update a connection to another user" + } + }, + "/conversations": { + "post": { + "description": " [internal route ID: \"create-group-conversation\"]\n\nThis returns 201 when a new conversation is created, and 200 when the conversation already existed
Calls federation service galley on on-conversation-updated
Calls federation service galley on on-conversation-created
Calls federation service brig on get-not-fully-connected-backends
Calls federation service brig on api-version", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewConv" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV6v6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV6v6" + } + } + }, + "description": "Conversation existed", + "headers": { + "Location": { + "description": "Conversation ID", + "schema": { + "format": "uuid", + "type": "string" + } + } + } + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGroupConversationv6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CreateGroupConversationv6" + } + } + }, + "description": "Conversation created", + "headers": { + "Location": { + "description": "Conversation ID", + "schema": { + "format": "uuid", + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled", + "non-empty-member-list" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nAttempting to add group members outside MLS (label: `non-empty-member-list`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "missing-legalhold-consent", + "message": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "missing-legalhold-consent", + "operation-denied", + "no-team-member", + "not-connected", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nUsers are not connected (label: `not-connected`)\n\nConversation access denied (label: `access-denied`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "non_federating_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "non_federating_backends" + ], + "type": "object" + } + } + }, + "description": "Adding members to the conversation is not possible because the backends involved do not form a fully connected graph" + }, + "533": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "unreachable_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "unreachable_backends" + ], + "type": "object" + } + } + }, + "description": "Some domains are unreachable" + } + }, + "summary": "Create a new conversation" + } + }, + "/conversations/code-check": { + "post": { + "description": " [internal route ID: \"code-check\"]\n\nIf the guest links team feature is disabled, this will fail with 404 CodeNotFound.Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/join` which responds with 409 GuestLinksDisabled if guest links are disabled.", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationCode" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Valid" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-conversation-password", + "message": "Invalid conversation password" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-conversation-password" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid conversation password (label: `invalid-conversation-password`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "no-conversation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)" + } + }, + "summary": "Check validity of a conversation code." + } + }, + "/conversations/join": { + "get": { + "description": " [internal route ID: \"get-conversation-by-reusable-code\"]\n\n", + "parameters": [ + { + "in": "query", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "code", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationCoverView" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "access-denied", + "invalid-conversation-password" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid conversation password (label: `invalid-conversation-password`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "no-conversation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "guest-links-disabled", + "message": "The guest link feature is disabled and all guest links have been revoked" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "guest-links-disabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)" + } + }, + "summary": "Get limited conversation information by key/code pair" + }, + "post": { + "description": " [internal route ID: \"join-conversation-by-code-unqualified\"]\n\nIf the guest links team feature is disabled, this will fail with 409 GuestLinksDisabled.Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/code-check` which responds with 404 CodeNotFound if guest links are disabled.Calls federation service galley on on-conversation-updated", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/JoinConversationByCode" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Conversation joined" + }, + "204": { + "description": "Conversation unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "too-many-members", + "message": "Maximum number of members per conversation reached" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-members", + "no-team-member", + "invalid-op", + "access-denied", + "invalid-conversation-password" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Maximum number of members per conversation reached (label: `too-many-members`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid conversation password (label: `invalid-conversation-password`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "no-conversation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "guest-links-disabled", + "message": "The guest link feature is disabled and all guest links have been revoked" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "guest-links-disabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)" + } + }, + "summary": "Join a conversation using a reusable code" + } + }, + "/conversations/list": { + "post": { + "description": " [internal route ID: \"list-conversations\"]\n\nCalls federation service galley on get-conversations", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ListConversations" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationsResponse" + } + } + }, + "description": "" + } + }, + "summary": "Get conversation metadata for a list of conversation ids" + } + }, + "/conversations/list-ids": { + "post": { + "description": " [internal route ID: \"list-conversation-ids\"]\n\nThe IDs returned by this endpoint are paginated. To get the first page, make a call with the `paging_state` field set to `null` (or omitted). Whenever the `has_more` field of the response is set to `true`, more results are available, and they can be obtained by calling the endpoint again, but this time passing the value of `paging_state` returned by the previous call. One can continue in this fashion until all results are returned, which is indicated by `has_more` being `false`. Note that `paging_state` should be considered an opaque token. It should not be inspected, or stored, or reused across multiple unrelated invocations of the endpoint.", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/GetPaginated_ConversationIds" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationIds_Page" + } + } + }, + "description": "" + } + }, + "summary": "Get all conversation IDs." + } + }, + "/conversations/mls-self": { + "get": { + "description": " [internal route ID: \"get-mls-self-conversation\"]\n\n", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Conversation" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Conversation" + } + } + }, + "description": "The MLS self-conversation" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)" + } + }, + "summary": "Get the user's MLS self-conversation" + } + }, + "/conversations/one2one": { + "post": { + "description": " [internal route ID: \"create-one-to-one-conversation\"]\n\nCalls federation service galley on on-conversation-created", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewConv" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV3v3" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV3v3" + } + } + }, + "description": "Conversation existed", + "headers": { + "Location": { + "description": "Conversation ID", + "schema": { + "format": "uuid", + "type": "string" + } + } + } + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV3v3" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV3v3" + } + } + }, + "description": "Conversation created", + "headers": { + "Location": { + "description": "Conversation ID", + "schema": { + "format": "uuid", + "type": "string" + } + } + } + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "missing-legalhold-consent", + "message": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "missing-legalhold-consent", + "operation-denied", + "not-connected", + "no-team-member", + "non-binding-team-members", + "invalid-op", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nInsufficient permissions (label: `operation-denied`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nBoth users must be members of the same binding team (label: `non-binding-team-members`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team", + "non-binding-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Team not found (label: `no-team`)\n\nNot a member of a binding team (label: `non-binding-team`)" + }, + "533": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "unreachable_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "unreachable_backends" + ], + "type": "object" + } + } + }, + "description": "Some domains are unreachable" + } + }, + "summary": "Create a 1:1 conversation" + } + }, + "/conversations/one2one/{usr_domain}/{usr}": { + "get": { + "description": " [internal route ID: \"get-one-to-one-mls-conversation@v6\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "usr_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "usr", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MLSOne2OneConversation_MLSPublicKey" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSOne2OneConversation_MLSPublicKey" + } + } + }, + "description": "MLS 1-1 conversation" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "not-connected", + "message": "Users are not connected" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-connected" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Users are not connected (label: `not-connected`)" + } + }, + "summary": "Get an MLS 1:1 conversation" + } + }, + "/conversations/self": { + "post": { + "description": " [internal route ID: \"create-self-conversation\"]\n\n", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV6v6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV6v6" + } + } + }, + "description": "Conversation existed", + "headers": { + "Location": { + "description": "Conversation ID", + "schema": { + "format": "uuid", + "type": "string" + } + } + } + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV6v6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV6v6" + } + } + }, + "description": "Conversation created", + "headers": { + "Location": { + "description": "Conversation ID", + "schema": { + "format": "uuid", + "type": "string" + } + } + } + } + }, + "summary": "Create a self-conversation" + } + }, + "/conversations/{Conversation ID}/bots": { + "post": { + "description": " [internal route ID: \"add-bot\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "Conversation ID", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AddBot" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddBotResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AddBotResponse" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "service-disabled", + "message": "The desired service is currently disabled." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "service-disabled", + "too-many-members", + "invalid-conversation", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The desired service is currently disabled. (label: `service-disabled`)\n\nMaximum number of members per conversation reached. (label: `too-many-members`)\n\nThe operation is not allowed in this conversation. (label: `invalid-conversation`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Add bot" + } + }, + "/conversations/{Conversation ID}/bots/{Bot ID}": { + "delete": { + "description": " [internal route ID: \"remove-bot\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "Conversation ID", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "Bot ID", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoveBotResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/RemoveBotResponse" + } + } + }, + "description": "User found" + }, + "204": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-conversation", + "message": "The operation is not allowed in this conversation." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-conversation", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The operation is not allowed in this conversation. (label: `invalid-conversation`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Remove bot" + } + }, + "/conversations/{cnv_domain}/{cnv}": { + "get": { + "description": " [internal route ID: \"get-conversation\"]\n\nCalls federation service galley on get-conversations", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Conversation" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Get a conversation by ID" + } + }, + "/conversations/{cnv_domain}/{cnv}/access": { + "put": { + "description": " [internal route ID: \"update-conversation-access\"]\n\nCalls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationAccessData" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Access updated" + }, + "204": { + "description": "Access unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid target access" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid target access (label: `invalid-op`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nInsufficient authorization (missing modify_conversation_access) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update access modes for a conversation" + } + }, + "/conversations/{cnv_domain}/{cnv}/groupinfo": { + "get": { + "description": " [internal route ID: \"get-group-info\"]\n\nCalls federation service galley on query-group-info", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "message/mls": { + "schema": { + "$ref": "#/components/schemas/GroupInfoData" + } + } + }, + "description": "The group information" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "mls-missing-group-info", + "message": "The conversation has no group information" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-missing-group-info", + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nThe conversation has no group information (label: `mls-missing-group-info`)\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Get MLS group information" + } + }, + "/conversations/{cnv_domain}/{cnv}/members": { + "post": { + "description": " [internal route ID: \"add-members-to-conversation\"]\n\nCalls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/InviteQualified" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Conversation updated" + }, + "204": { + "description": "Conversation unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "missing-legalhold-consent", + "message": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "missing-legalhold-consent", + "not-connected", + "no-team-member", + "access-denied", + "too-many-members", + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nMaximum number of members per conversation reached (label: `too-many-members`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInsufficient authorization (missing add_conversation_member) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "non_federating_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "non_federating_backends" + ], + "type": "object" + } + } + }, + "description": "Adding members to the conversation is not possible because the backends involved do not form a fully connected graph" + }, + "533": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "unreachable_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "unreachable_backends" + ], + "type": "object" + } + } + }, + "description": "Some domains are unreachable" + } + }, + "summary": "Add qualified members to an existing conversation." + } + }, + "/conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}": { + "delete": { + "description": " [internal route ID: \"remove-member\"]\n\nCalls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated
Calls federation service galley on leave-conversation", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "usr_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Target User ID", + "in": "path", + "name": "usr", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Member removed" + }, + "204": { + "description": "No change" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` or `usr_domain` or `usr` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Remove a member from a conversation" + }, + "put": { + "description": " [internal route ID: \"update-other-member\"]\n\n**Note**: at least one field has to be provided.Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "usr_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Target User ID", + "in": "path", + "name": "usr", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OtherMemberUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Membership updated" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nInvalid target (label: `invalid-op`)\n\nInsufficient authorization (missing modify_other_conversation_member) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation-member", + "message": "Conversation member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation-member", + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` or `usr_domain` or `usr` not found\n\nConversation member not found (label: `no-conversation-member`)\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update membership of the specified user" + } + }, + "/conversations/{cnv_domain}/{cnv}/message-timer": { + "put": { + "description": " [internal route ID: \"update-conversation-message-timer\"]\n\nCalls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationMessageTimerUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Message timer updated" + }, + "204": { + "description": "Message timer unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_message_timer) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update the message timer for a conversation" + } + }, + "/conversations/{cnv_domain}/{cnv}/name": { + "put": { + "description": " [internal route ID: \"update-conversation-name\"]\n\nCalls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationRename" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Name unchanged" + }, + "204": { + "description": "Name updated" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_conversation_name) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update conversation name" + } + }, + "/conversations/{cnv_domain}/{cnv}/proteus/messages": { + "post": { + "description": " [internal route ID: \"post-proteus-message\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts `client_mismatch_strategy` in the body. It can have these values:\n- `report_all`: When set, the message is not sent if any clients are missing. The missing clients are reported in the response.\n- `ignore_all`: When set, no checks about missing clients are carried out.\n- `report_only`: Takes a list of qualified UserIDs. If any clients of the listed users are missing, the message is not sent. The missing clients are reported in the response.\n- `ignore_only`: Takes a list of qualified UserIDs. If any clients of the non-listed users are missing, the message is not sent. The missing clients are reported in the response.\n\nThe sending of messages in a federated conversation could theoretically fail partially. To make this case unlikely, the backend first gets a list of clients from all the involved backends and then tries to send a message. So, if any backend is down, the message is not propagated to anyone. But the actual message fan out to multiple backends could still fail partially. This type of failure is reported as a 201, the clients for which the message sending failed are part of the response body.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.Calls federation service galley on send-message
Calls federation service galley on on-message-sent
Calls federation service brig on get-user-clients", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/x-protobuf": { + "schema": { + "$ref": "#/components/schemas/QualifiedNewOtrMessage" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + } + }, + "description": "Message sent" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` or Conversation not found (label: `no-conversation`)" + }, + "412": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + } + }, + "description": "Missing clients" + } + }, + "summary": "Post an encrypted message to a conversation (accepts only Protobuf)" + } + }, + "/conversations/{cnv_domain}/{cnv}/protocol": { + "put": { + "description": " [internal route ID: \"update-conversation-protocol\"]\n\n**Note**: Only proteus->mixed upgrade is supported.", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ProtocolUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Conversation updated" + }, + "204": { + "description": "Conversation unchanged" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-migration-criteria-not-satisfied", + "message": "The migration criteria for mixed to MLS protocol transition are not satisfied for this conversation" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-migration-criteria-not-satisfied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nThe migration criteria for mixed to MLS protocol transition are not satisfied for this conversation (label: `mls-migration-criteria-not-satisfied`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member", + "invalid-op", + "action-denied", + "invalid-protocol-transition" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nProtocol transition is invalid (label: `invalid-protocol-transition`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team", + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nTeam not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update the protocol of the conversation" + } + }, + "/conversations/{cnv_domain}/{cnv}/receipt-mode": { + "put": { + "description": " [internal route ID: \"update-conversation-receipt-mode\"]\n\nCalls federation service brig on get-users-by-ids
Calls federation service galley on update-conversation
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationReceiptModeUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Receipt mode updated" + }, + "204": { + "description": "Receipt mode unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_receipt_mode) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update receipt mode for a conversation" + } + }, + "/conversations/{cnv_domain}/{cnv}/self": { + "put": { + "description": " [internal route ID: \"update-conversation-self\"]\n\n**Note**: at least one field has to be provided.", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MemberUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Update successful" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update self membership properties" + } + }, + "/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}": { + "delete": { + "description": " [internal route ID: \"delete-subconversation\"]\n\nCalls federation service galley on delete-sub-conversation", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "subconv", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeleteSubConversationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + } + }, + "description": "Deletion successful" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "mls-stale-message", + "message": "The conversation epoch in a message is too old" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-stale-message" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The conversation epoch in a message is too old (label: `mls-stale-message`)" + } + }, + "summary": "Delete an MLS subconversation" + }, + "get": { + "description": " [internal route ID: \"get-subconversation\"]\n\nCalls federation service galley on get-sub-conversation", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "subconv", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicSubConversation" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PublicSubConversation" + } + } + }, + "description": "Subconversation" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "mls-subconv-unsupported-convtype", + "message": "MLS subconversations are only supported for regular conversations" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-subconv-unsupported-convtype", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS subconversations are only supported for regular conversations (label: `mls-subconv-unsupported-convtype`)\n\nConversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Get information about an MLS subconversation" + } + }, + "/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}/groupinfo": { + "get": { + "description": " [internal route ID: \"get-subconversation-group-info\"]\n\nCalls federation service galley on query-group-info", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "subconv", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "message/mls": { + "schema": { + "$ref": "#/components/schemas/GroupInfoData" + } + } + }, + "description": "The group information" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "mls-missing-group-info", + "message": "The conversation has no group information" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-missing-group-info", + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` or `subconv` not found\n\nThe conversation has no group information (label: `mls-missing-group-info`)\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Get MLS group information of subconversation" + } + }, + "/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}/self": { + "delete": { + "description": " [internal route ID: \"leave-subconversation\"]\n\nCalls federation service galley on leave-sub-conversation
Calls federation service galley on on-mls-message-sent", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "subconv", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled", + "mls-protocol-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nMLS protocol error (label: `mls-protocol-error`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "mls-stale-message", + "message": "The conversation epoch in a message is too old" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-stale-message" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The conversation epoch in a message is too old (label: `mls-stale-message`)" + } + }, + "summary": "Leave an MLS subconversation" + } + }, + "/conversations/{cnv_domain}/{cnv}/typing": { + "post": { + "description": " [internal route ID: \"member-typing-qualified\"]\n\nCalls federation service galley on on-typing-indicator-updated
Calls federation service galley on update-typing-indicator", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TypingData" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Notification sent" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Sending typing notifications" + } + }, + "/conversations/{cnv}": { + "put": { + "deprecated": true, + "description": " [internal route ID: \"update-conversation-name-deprecated\"]\n\nUse `/conversations/:domain/:conv/name` instead.Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationRename" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Name updated" + }, + "204": { + "description": "Name unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_conversation_name) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update conversation name (deprecated)" + } + }, + "/conversations/{cnv}/code": { + "delete": { + "description": " [internal route ID: \"remove-code-unqualified\"]\n\n", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Conversation code deleted." + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Delete conversation code" + }, + "get": { + "description": " [internal route ID: \"get-code\"]\n\n", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationCodeInfo" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationCodeInfo" + } + } + }, + "description": "Conversation Code" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "no-conversation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "guest-links-disabled", + "message": "The guest link feature is disabled and all guest links have been revoked" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "guest-links-disabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)" + } + }, + "summary": "Get existing conversation code" + }, + "post": { + "description": " [internal route ID: \"create-conversation-code-unqualified\"]\n\n\nOAuth scope: `write:conversations_code`", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CreateConversationCodeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationCodeInfo" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationCodeInfo" + } + } + }, + "description": "Conversation code already exists." + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Conversation code created." + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "create-conv-code-conflict", + "message": "Conversation code already exists with a different password setting than the requested one." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "create-conv-code-conflict", + "guest-links-disabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation code already exists with a different password setting than the requested one. (label: `create-conv-code-conflict`)\n\nThe guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)" + } + }, + "summary": "Create or recreate a conversation code" + } + }, + "/conversations/{cnv}/features/conversationGuestLinks": { + "get": { + "description": " [internal route ID: \"get-conversation-guest-links-status\"]\n\n", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/GuestLinksConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Get the status of the guest links feature for a conversation that potentially has been created by someone from another team." + } + }, + "/conversations/{cnv}/members/{usr}": { + "put": { + "deprecated": true, + "description": " [internal route ID: \"update-other-member-unqualified\"]\n\nUse `PUT /conversations/:cnv_domain/:cnv/members/:usr_domain/:usr` insteadCalls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Target User ID", + "in": "path", + "name": "usr", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OtherMemberUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Membership updated" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nInvalid target (label: `invalid-op`)\n\nInsufficient authorization (missing modify_other_conversation_member) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation-member", + "message": "Conversation member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation-member", + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` or `usr` not found\n\nConversation member not found (label: `no-conversation-member`)\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update membership of the specified user (deprecated)" + } + }, + "/conversations/{cnv}/message-timer": { + "put": { + "deprecated": true, + "description": " [internal route ID: \"update-conversation-message-timer-unqualified\"]\n\nUse `/conversations/:domain/:cnv/message-timer` instead.Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationMessageTimerUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Message timer updated" + }, + "204": { + "description": "Message timer unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_message_timer) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update the message timer for a conversation (deprecated)" + } + }, + "/conversations/{cnv}/name": { + "put": { + "deprecated": true, + "description": " [internal route ID: \"update-conversation-name-unqualified\"]\n\nUse `/conversations/:domain/:conv/name` instead.Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationRename" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Name updated" + }, + "204": { + "description": "Name unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_conversation_name) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update conversation name (deprecated)" + } + }, + "/conversations/{cnv}/otr/messages": { + "post": { + "description": " [internal route ID: \"post-otr-message-unqualified\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts two query params:\n- `ignore_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are ignored.\n - When 'false' all missing clients are reported.\n - When comma separated list of user-ids, only clients for listed users are ignored.\n- `report_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are reported.\n - When 'false' all missing clients are ignored.\n - When comma separated list of user-ids, only clients for listed users are reported.\n\nApart from these, the request body also accepts `report_missing` which can only be a list of user ids and behaves the same way as the query parameter.\n\nAll three of these should be considered mutually exclusive. The server however does not error if more than one is specified, it reads them in this order of precedence:\n- `report_missing` in the request body has highest precedence.\n- `ignore_missing` in the query param is the next.\n- `report_missing` in the query param has the lowest precedence.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.Calls federation service brig on get-user-clients
Calls federation service galley on on-message-sent", + "parameters": [ + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "ignore_missing", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "report_missing", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/new-otr-message" + } + }, + "application/x-protobuf": { + "schema": { + "$ref": "#/components/schemas/new-otr-message" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + } + }, + "description": "Message sent" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` or Conversation not found (label: `no-conversation`)" + }, + "412": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + } + }, + "description": "Missing clients" + } + }, + "summary": "Post an encrypted message to a conversation (accepts JSON or Protobuf)" + } + }, + "/conversations/{cnv}/receipt-mode": { + "put": { + "deprecated": true, + "description": " [internal route ID: \"update-conversation-receipt-mode-unqualified\"]\n\nUse `PUT /conversations/:domain/:cnv/receipt-mode` instead.Calls federation service brig on get-users-by-ids
Calls federation service galley on update-conversation
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationReceiptModeUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Receipt mode updated" + }, + "204": { + "description": "Receipt mode unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_receipt_mode) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update receipt mode for a conversation (deprecated)" + } + }, + "/conversations/{cnv}/roles": { + "get": { + "description": " [internal route ID: \"get-conversation-roles\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationRolesList" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Get existing roles available for the given conversation" + } + }, + "/conversations/{cnv}/self": { + "get": { + "deprecated": true, + "description": " [internal route ID: \"get-conversation-self-unqualified\"]\n\n", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Member" + } + } + }, + "description": "" + } + }, + "summary": "Get self membership properties (deprecated)" + }, + "put": { + "deprecated": true, + "description": " [internal route ID: \"update-conversation-self-unqualified\"]\n\nUse `/conversations/:domain/:conv/self` instead.", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MemberUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Update successful" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update self membership properties (deprecated)" + } + }, + "/cookies": { + "get": { + "description": " [internal route ID: \"list-cookies\"]\n\n", + "parameters": [ + { + "description": "Filter by label (comma-separated list)", + "in": "query", + "name": "labels", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CookieList" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CookieList" + } + } + }, + "description": "List of cookies" + } + }, + "summary": "Retrieve the list of cookies currently stored for the user" + } + }, + "/cookies/remove": { + "post": { + "description": " [internal route ID: \"remove-cookies\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/RemoveCookies" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Cookies revoked" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)" + } + }, + "summary": "Revoke stored cookies" + } + }, + "/custom-backend/by-domain/{domain}": { + "get": { + "description": " [internal route ID: \"get-custom-backend-by-domain\"]\n\n", + "parameters": [ + { + "description": "URL-encoded email domain", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CustomBackend" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "custom-backend-not-found", + "message": "Custom backend not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "custom-backend-not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`domain` not found\n\nCustom backend not found (label: `custom-backend-not-found`)" + } + }, + "summary": "Shows information about custom backends related to a given email domain" + } + }, + "/delete": { + "post": { + "description": " [internal route ID: \"verify-delete\"]\n\nCalls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/VerifyDeleteUser" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Deletion is initiated." + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-code", + "message": "Invalid verification code" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid verification code (label: `invalid-code`)" + } + }, + "summary": "Verify account deletion with a code." + } + }, + "/feature-configs": { + "get": { + "description": " [internal route ID: \"get-all-feature-configs-for-user\"]\n\nGets feature configs for a user. If the user is a member of a team and has the required permissions, this will return the team's feature configs.If the user is not a member of a team, this will return the personal feature configs (the server defaults).\nOAuth scope: `read:feature_configs`", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AllTeamFeatures" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Team not found (label: `no-team`)" + } + }, + "summary": "Gets feature configs for a user" + } + }, + "/identity-providers": { + "get": { + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/IdPList" + } + } + }, + "description": "" + } + } + }, + "post": { + "parameters": [ + { + "in": "query", + "name": "replaces", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "api_version", + "required": false, + "schema": { + "default": "v2", + "enum": [ + "v1", + "v2" + ], + "type": "string" + } + }, + { + "in": "query", + "name": "handle", + "required": false, + "schema": { + "maxLength": 1, + "minLength": 32, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/IdPMetadataInfo" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/IdPMetadataInfo" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/IdPConfig_WireIdP" + } + } + }, + "description": "" + } + } + } + }, + "/identity-providers/{id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "purge", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "204": { + "description": "" + } + } + }, + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/IdPConfig_WireIdP" + } + } + }, + "description": "" + } + } + }, + "put": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "handle", + "required": false, + "schema": { + "maxLength": 1, + "minLength": 32, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/IdPMetadataInfo" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/IdPMetadataInfo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/IdPConfig_WireIdP" + } + } + }, + "description": "" + } + } + } + }, + "/identity-providers/{id}/raw": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + }, + "description": "" + } + } + } + }, + "/list-connections": { + "post": { + "description": " [internal route ID: \"list-connections\"]\n\nThe IDs returned by this endpoint are paginated. To get the first page, make a call with the `paging_state` field set to `null` (or omitted). Whenever the `has_more` field of the response is set to `true`, more results are available, and they can be obtained by calling the endpoint again, but this time passing the value of `paging_state` returned by the previous call. One can continue in this fashion until all results are returned, which is indicated by `has_more` being `false`. Note that `paging_state` should be considered an opaque token. It should not be inspected, or stored, or reused across multiple unrelated invocations of the endpoint.", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/GetPaginated_Connections" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Connections_Page" + } + } + }, + "description": "" + } + }, + "summary": "List the connections to other users, including remote users" + } + }, + "/list-users": { + "post": { + "description": " [internal route ID: \"list-users-by-ids-or-handles\"]\n\nThe 'qualified_ids' and 'qualified_handles' parameters are mutually exclusive.Calls federation service brig on get-users-by-ids", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ListUsersQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ListUsersById" + } + } + }, + "description": "" + } + }, + "summary": "List users" + } + }, + "/login": { + "post": { + "description": " [internal route ID: \"login\"]\n\nLogins are throttled at the server's discretionCalls federation service brig on send-connection-action", + "parameters": [ + { + "description": "Request a persistent cookie instead of a session cookie", + "in": "query", + "name": "persist", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Login" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessToken" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AccessToken" + } + } + }, + "description": "OK", + "headers": { + "Set-Cookie": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "pending-activation", + "suspended", + "invalid-credentials" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nAccount pending activation (label: `pending-activation`)\n\nAccount suspended (label: `suspended`)\n\nAuthentication failed (label: `invalid-credentials`)" + } + }, + "summary": "Authenticate a user to obtain a cookie and first access token" + } + }, + "/mls/commit-bundles": { + "post": { + "description": " [internal route ID: \"mls-commit-bundle\"]\n\n\n\n**Note**: this endpoint can execute proposals, and therefore return all possible errors associated with adding or removing members to a conversation, in addition to the ones listed below. See the documentation of [POST /conversations/{cnv}/members/v2](#/default/post_conversations__cnv__members_v2) and [POST /conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}](#/default/delete_conversations__cnv_domain___cnv__members__usr_domain___usr_) for more details on the possible error responses of each type of proposal.
Calls federation service brig on api-version
Calls federation service brig on get-users-by-ids
Calls federation service brig on get-mls-clients
Calls federation service galley on on-conversation-updated
Calls federation service galley on send-mls-commit-bundle
Calls federation service galley on mls-welcome
Calls federation service galley on on-mls-message-sent", + "requestBody": { + "content": { + "message/mls": { + "schema": { + "$ref": "#/components/schemas/CommitBundle" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MLSMessageSendingStatus" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSMessageSendingStatus" + } + } + }, + "description": "Commit accepted and forwarded" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-welcome-mismatch", + "message": "The list of targets of a welcome message does not match the list of new clients in a group" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-welcome-mismatch", + "mls-self-removal-not-allowed", + "mls-protocol-error", + "mls-not-enabled", + "mls-invalid-leaf-node-index", + "mls-group-conversation-mismatch", + "mls-commit-missing-references", + "mls-client-sender-user-mismatch" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nThe list of targets of a welcome message does not match the list of new clients in a group (label: `mls-welcome-mismatch`)\n\nSelf removal from group is not allowed (label: `mls-self-removal-not-allowed`)\n\nMLS protocol error (label: `mls-protocol-error`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nA referenced leaf node index points to a blank or non-existing node (label: `mls-invalid-leaf-node-index`)\n\nConversation ID resolved from Group ID does not match submitted Conversation ID (label: `mls-group-conversation-mismatch`)\n\nThe commit is not referencing all pending proposals (label: `mls-commit-missing-references`)\n\nUser ID resolved from Client ID does not match message's sender user ID (label: `mls-client-sender-user-mismatch`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "mls-subconv-join-parent-missing", + "message": "MLS client cannot join the subconversation because it is not member of the parent conversation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-subconv-join-parent-missing", + "missing-legalhold-consent", + "legalhold-not-enabled", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS client cannot join the subconversation because it is not member of the parent conversation (label: `mls-subconv-join-parent-missing`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nConversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "mls-proposal-not-found", + "message": "A proposal referenced in a commit message could not be found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-proposal-not-found", + "no-conversation", + "no-conversation-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "A proposal referenced in a commit message could not be found (label: `mls-proposal-not-found`)\n\nConversation not found (label: `no-conversation`)\n\nConversation member not found (label: `no-conversation-member`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "non_federating_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "non_federating_backends" + ], + "type": "object" + } + } + }, + "description": "Adding members to the conversation is not possible because the backends involved do not form a fully connected graph\n\nThe conversation epoch in a message is too old (label: `mls-stale-message`)\n\nA proposal of type Add or Remove does not apply to the full list of clients for a user (label: `mls-client-mismatch`)" + }, + "422": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 422, + "label": "mls-unsupported-proposal", + "message": "Unsupported proposal type" + }, + "properties": { + "code": { + "enum": [ + 422 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-unsupported-proposal", + "mls-unsupported-message" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unsupported proposal type (label: `mls-unsupported-proposal`)\n\nAttempted to send a message with an unsupported combination of content type and wire format (label: `mls-unsupported-message`)" + }, + "533": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "unreachable_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "unreachable_backends" + ], + "type": "object" + } + } + }, + "description": "Some domains are unreachable" + } + }, + "summary": "Post a MLS CommitBundle" + } + }, + "/mls/key-packages/claim/{user_domain}/{user}": { + "post": { + "description": " [internal route ID: \"mls-key-packages-claim\"]\n\nOnly key packages for the specified ciphersuite are claimed. For backwards compatibility, the `ciphersuite` parameter is optional, defaulting to ciphersuite 0x0001 when omitted.", + "parameters": [ + { + "in": "path", + "name": "user_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "user", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Ciphersuite in hex format (e.g. 0xf031) - default is 0x0001", + "in": "query", + "name": "ciphersuite", + "required": false, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeyPackageBundle" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/KeyPackageBundle" + } + } + }, + "description": "Claimed key packages" + } + }, + "summary": "Claim one key package for each client of the given user" + } + }, + "/mls/key-packages/self/{client}": { + "delete": { + "description": " [internal route ID: \"mls-key-packages-delete\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Ciphersuite in hex format (e.g. 0xf031) - default is 0x0001", + "in": "query", + "name": "ciphersuite", + "required": false, + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeleteKeyPackages" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "OK" + } + }, + "summary": "Delete all key packages for a given ciphersuite and client" + }, + "post": { + "description": " [internal route ID: \"mls-key-packages-upload\"]\n\nThe request body should be a json object containing a list of base64-encoded key packages.", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/KeyPackageUpload" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Key packages uploaded" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-protocol-error", + "message": "MLS protocol error" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-protocol-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nMLS protocol error (label: `mls-protocol-error`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "mls-identity-mismatch", + "message": "Key package credential does not match qualified client ID" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-identity-mismatch" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Key package credential does not match qualified client ID (label: `mls-identity-mismatch`)" + } + }, + "summary": "Upload a fresh batch of key packages" + }, + "put": { + "description": " [internal route ID: \"mls-key-packages-replace\"]\n\nThe request body should be a json object containing a list of base64-encoded key packages. Use this sparingly.", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Comma-separated list of ciphersuites in hex format (e.g. 0xf031) - default is 0x0001", + "in": "query", + "name": "ciphersuites", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/KeyPackageUpload" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Key packages replaced" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-protocol-error", + "message": "MLS protocol error" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-protocol-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body` or `ciphersuites`\n\nMLS protocol error (label: `mls-protocol-error`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "mls-identity-mismatch", + "message": "Key package credential does not match qualified client ID" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-identity-mismatch" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Key package credential does not match qualified client ID (label: `mls-identity-mismatch`)" + } + }, + "summary": "Upload a fresh batch of key packages and replace the old ones" + } + }, + "/mls/key-packages/self/{client}/count": { + "get": { + "description": " [internal route ID: \"mls-key-packages-count\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Ciphersuite in hex format (e.g. 0xf031) - default is 0x0001", + "in": "query", + "name": "ciphersuite", + "required": false, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OwnKeyPackages" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OwnKeyPackages" + } + } + }, + "description": "Number of key packages" + } + }, + "summary": "Return the number of unclaimed key packages for a given ciphersuite and client" + } + }, + "/mls/messages": { + "post": { + "description": " [internal route ID: \"mls-message\"]\n\n\n\n**Note**: this endpoint can execute proposals, and therefore return all possible errors associated with adding or removing members to a conversation, in addition to the ones listed below. See the documentation of [POST /conversations/{cnv}/members/v2](#/default/post_conversations__cnv__members_v2) and [POST /conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}](#/default/delete_conversations__cnv_domain___cnv__members__usr_domain___usr_) for more details on the possible error responses of each type of proposal.
Calls federation service brig on get-mls-clients
Calls federation service galley on on-conversation-updated
Calls federation service galley on send-mls-message
Calls federation service galley on on-mls-message-sent", + "requestBody": { + "content": { + "message/mls": { + "schema": { + "$ref": "#/components/schemas/MLSMessage" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MLSMessageSendingStatus" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSMessageSendingStatus" + } + } + }, + "description": "Message sent" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-self-removal-not-allowed", + "message": "Self removal from group is not allowed" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-self-removal-not-allowed", + "mls-protocol-error", + "mls-not-enabled", + "mls-invalid-leaf-node-index", + "mls-group-conversation-mismatch", + "mls-commit-missing-references", + "mls-client-sender-user-mismatch" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nSelf removal from group is not allowed (label: `mls-self-removal-not-allowed`)\n\nMLS protocol error (label: `mls-protocol-error`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nA referenced leaf node index points to a blank or non-existing node (label: `mls-invalid-leaf-node-index`)\n\nConversation ID resolved from Group ID does not match submitted Conversation ID (label: `mls-group-conversation-mismatch`)\n\nThe commit is not referencing all pending proposals (label: `mls-commit-missing-references`)\n\nUser ID resolved from Client ID does not match message's sender user ID (label: `mls-client-sender-user-mismatch`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "mls-subconv-join-parent-missing", + "message": "MLS client cannot join the subconversation because it is not member of the parent conversation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-subconv-join-parent-missing", + "missing-legalhold-consent", + "legalhold-not-enabled", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS client cannot join the subconversation because it is not member of the parent conversation (label: `mls-subconv-join-parent-missing`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nConversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "mls-proposal-not-found", + "message": "A proposal referenced in a commit message could not be found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-proposal-not-found", + "no-conversation", + "no-conversation-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "A proposal referenced in a commit message could not be found (label: `mls-proposal-not-found`)\n\nConversation not found (label: `no-conversation`)\n\nConversation member not found (label: `no-conversation-member`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "non_federating_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "non_federating_backends" + ], + "type": "object" + } + } + }, + "description": "Adding members to the conversation is not possible because the backends involved do not form a fully connected graph\n\nThe conversation epoch in a message is too old (label: `mls-stale-message`)\n\nA proposal of type Add or Remove does not apply to the full list of clients for a user (label: `mls-client-mismatch`)" + }, + "422": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 422, + "label": "mls-unsupported-proposal", + "message": "Unsupported proposal type" + }, + "properties": { + "code": { + "enum": [ + 422 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-unsupported-proposal", + "mls-unsupported-message" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unsupported proposal type (label: `mls-unsupported-proposal`)\n\nAttempted to send a message with an unsupported combination of content type and wire format (label: `mls-unsupported-message`)" + }, + "533": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "unreachable_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "unreachable_backends" + ], + "type": "object" + } + } + }, + "description": "Some domains are unreachable" + } + }, + "summary": "Post an MLS message" + } + }, + "/mls/public-keys": { + "get": { + "description": " [internal route ID: \"mls-public-keys\"]\n\nThe format of the returned key is determined by the `format` query parameter:\n - raw (default): base64-encoded raw public keys\n - jwk: keys are nested objects in JWK format.", + "parameters": [ + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "enum": [ + "raw", + "jwk" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MLSKeysByPurpose" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSKeysByPurpose" + } + } + }, + "description": "Public keys" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `format`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)" + } + }, + "summary": "Get public keys used by the backend to sign external proposals" + } + }, + "/notifications": { + "get": { + "description": " [internal route ID: \"get-notifications\"]\n\n", + "parameters": [ + { + "description": "Only return notifications more recent than this", + "in": "query", + "name": "since", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Only return notifications targeted at the given client", + "in": "query", + "name": "client", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Maximum number of notifications to return", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int32", + "maximum": 10000, + "minimum": 100, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueuedNotificationList" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/QueuedNotificationList" + } + } + }, + "description": "Notification list" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Some notifications not found (label: `not-found`)" + } + }, + "summary": "Fetch notifications" + } + }, + "/notifications/last": { + "get": { + "description": " [internal route ID: \"get-last-notification\"]\n\n", + "parameters": [ + { + "description": "Only return notifications targeted at the given client", + "in": "query", + "name": "client", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueuedNotification" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/QueuedNotification" + } + } + }, + "description": "Notification found" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Some notifications not found (label: `not-found`)" + } + }, + "summary": "Fetch the last notification" + } + }, + "/notifications/{id}": { + "get": { + "description": " [internal route ID: \"get-notification-by-id\"]\n\n", + "parameters": [ + { + "description": "Notification ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Only return notifications targeted at the given client", + "in": "query", + "name": "client", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueuedNotification" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/QueuedNotification" + } + } + }, + "description": "Notification found" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`id` or Some notifications not found (label: `not-found`)" + } + }, + "summary": "Fetch a notification by ID" + } + }, + "/oauth/applications": { + "get": { + "description": " [internal route ID: \"get-oauth-applications\"]\n\nGet all OAuth applications with active account access for a user.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/OAuthApplication" + }, + "type": "array" + } + }, + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/OAuthApplication" + }, + "type": "array" + } + } + }, + "description": "OAuth applications found" + } + }, + "summary": "Get OAuth applications with account access" + } + }, + "/oauth/applications/{OAuthClientId}": { + "delete": { + "description": " [internal route ID: \"revoke-oauth-account-access-v6\"]\n\n", + "parameters": [ + { + "description": "The ID of the OAuth client", + "in": "path", + "name": "OAuthClientId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "OAuth application access revoked" + } + }, + "summary": "Revoke account access from an OAuth application" + } + }, + "/oauth/applications/{OAuthClientId}/sessions/{RefreshTokenId}": { + "delete": { + "description": " [internal route ID: \"delete-oauth-refresh-token\"]\n\nRevoke an active OAuth session by providing the refresh token ID.", + "parameters": [ + { + "description": "The ID of the OAuth client", + "in": "path", + "name": "OAuthClientId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "The ID of the refresh token", + "in": "path", + "name": "RefreshTokenId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PasswordReqBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "OAuth client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`OAuthClientId` or `RefreshTokenId` not found\n\nOAuth client not found (label: `not-found`)" + } + }, + "summary": "Revoke an active OAuth session" + } + }, + "/oauth/authorization/codes": { + "post": { + "description": " [internal route ID: \"create-oauth-auth-code\"]\n\nCurrently only supports the 'code' response type, which corresponds to the authorization code flow.", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CreateOAuthAuthorizationCodeRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 400, + "label": "redirect-url-miss-match", + "message": "The redirect URL does not match the one registered with the client" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "redirect-url-miss-match" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "redirect-url-miss-match", + "message": "The redirect URL does not match the one registered with the client" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "redirect-url-miss-match" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Bad Request\n\nThe redirect URL does not match the one registered with the client (label: `redirect-url-miss-match`) or `body`", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + } + }, + "summary": "Create an OAuth authorization code" + } + }, + "/oauth/clients/{OAuthClientId}": { + "get": { + "description": " [internal route ID: \"get-oauth-client\"]\n\n", + "parameters": [ + { + "description": "The ID of the OAuth client", + "in": "path", + "name": "OAuthClientId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthClient" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OAuthClient" + } + } + }, + "description": "OAuth client found" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "forbidden", + "message": "OAuth is disabled" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "forbidden" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "OAuth is disabled (label: `forbidden`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "OAuth client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "OAuth client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`OAuthClientId` or OAuth client not found (label: `not-found`)\n\nOAuth client not found (label: `not-found`)" + } + }, + "summary": "Get OAuth client information" + } + }, + "/oauth/revoke": { + "post": { + "description": " [internal route ID: \"revoke-oauth-refresh-token\"]\n\nRevoke an access token.", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OAuthRevokeRefreshTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "forbidden", + "message": "Invalid refresh token" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "forbidden" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid refresh token (label: `forbidden`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "OAuth client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "OAuth client not found (label: `not-found`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "jwt-error", + "message": "Internal error while handling JWT token" + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "jwt-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Internal error while handling JWT token (label: `jwt-error`)" + } + }, + "summary": "Revoke an OAuth refresh token" + } + }, + "/oauth/token": { + "post": { + "description": " [internal route ID: \"create-oauth-access-token\"]\n\nObtain a new access token from an authorization code or a refresh token.", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Either_OAuthAccessTokenRequest_OAuthRefreshAccessTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OAuthAccessTokenResponse" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid_grant", + "message": "Invalid grant" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid_grant", + "forbidden" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid grant (label: `invalid_grant`)\n\nInvalid client credentials (label: `forbidden`)\n\nInvalid grant type (label: `forbidden`)\n\nInvalid refresh token (label: `forbidden`)\n\nOAuth is disabled (label: `forbidden`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "OAuth client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "OAuth client not found (label: `not-found`)\n\nOAuth authorization code not found (label: `not-found`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "jwt-error", + "message": "Internal error while handling JWT token" + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "jwt-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Internal error while handling JWT token (label: `jwt-error`)" + } + }, + "summary": "Create an OAuth access token" + } + }, + "/onboarding/v3": { + "post": { + "deprecated": true, + "description": " [internal route ID: \"onboarding\"]\n\nDEPRECATED: the feature has been turned off, the end-point does nothing and always returns '{\"results\":[],\"auto-connects\":[]}'.", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeprecatedMatchingResult" + } + } + }, + "description": "" + } + }, + "summary": "Upload contacts and invoke matching." + } + }, + "/password-reset": { + "post": { + "description": " [internal route ID: \"post-password-reset\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewPasswordReset" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Password reset code created and sent by email." + } + }, + "summary": "Initiate a password reset." + } + }, + "/password-reset/complete": { + "post": { + "description": " [internal route ID: \"post-password-reset-complete\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CompletePasswordReset" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Password reset successful." + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-code", + "message": "Invalid password reset code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)" + } + }, + "summary": "Complete a password reset." + } + }, + "/password-reset/{key}": { + "post": { + "deprecated": true, + "description": " [internal route ID: \"post-password-reset-key-deprecated\"]\n\nDEPRECATED: Use 'POST /password-reset/complete'.", + "parameters": [ + { + "description": "An opaque key for a pending password reset.", + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PasswordReset" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Password reset successful." + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-code", + "message": "Invalid password reset code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code", + "invalid-key" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)\n\nInvalid email or mobile number for password reset. (label: `invalid-key`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "password-must-differ", + "message": "For password reset, new and old password must be different." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-must-differ", + "code-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "For password reset, new and old password must be different. (label: `password-must-differ`)\n\nA password reset is already in progress. (label: `code-exists`)" + } + }, + "summary": "Complete a password reset." + } + }, + "/properties": { + "delete": { + "description": " [internal route ID: \"clear-properties\"]\n\n", + "responses": { + "200": { + "description": "Properties cleared" + } + }, + "summary": "Clear all properties" + }, + "get": { + "description": " [internal route ID: \"list-property-keys\"]\n\n", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ASCII" + }, + "type": "array" + } + }, + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/ASCII" + }, + "type": "array" + } + } + }, + "description": "List of property keys" + } + }, + "summary": "List all property keys" + } + }, + "/properties-values": { + "get": { + "description": " [internal route ID: \"list-properties\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PropertyKeysAndValues" + } + } + }, + "description": "" + } + }, + "summary": "List all properties with key and value" + } + }, + "/properties/{key}": { + "delete": { + "description": " [internal route ID: \"delete-property\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "format": "printable", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Property deleted" + } + }, + "summary": "Delete a property" + }, + "get": { + "description": " [internal route ID: \"get-property\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "format": "printable", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PropertyValue" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PropertyValue" + } + } + }, + "description": "The property value" + }, + "404": { + "description": "`key` or Property not found(**Note**: This error has an empty body for legacy reasons)" + } + }, + "summary": "Get a property value" + }, + "put": { + "description": " [internal route ID: \"set-property\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "format": "printable", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PropertyValue" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Property set" + } + }, + "summary": "Set a user property" + } + }, + "/provider": { + "delete": { + "description": " [internal route ID: \"provider-delete\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeleteProvider" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "invalid-provider", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nThe provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Delete a provider" + }, + "get": { + "description": " [internal route ID: \"provider-get-account\"]\n\n", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Provider" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Provider" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Provider not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Provider not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Provider not found. (label: `not-found`)\n\nProvider not found. (label: `not-found`)" + } + }, + "summary": "Get account" + }, + "put": { + "description": " [internal route ID: \"provider-update\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UpdateProvider" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-provider", + "message": "The provider does not exist." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-provider", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Update a provider" + } + }, + "/provider/activate": { + "get": { + "description": " [internal route ID: \"provider-activate\"]\n\n", + "parameters": [ + { + "in": "query", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "code", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderActivationResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ProviderActivationResponse" + } + } + }, + "description": "" + }, + "204": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-code", + "message": "Invalid verification code" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid verification code (label: `invalid-code`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Activate a provider" + } + }, + "/provider/assets": { + "post": { + "requestBody": { + "content": { + "multipart/mixed": { + "schema": { + "$ref": "#/components/schemas/AssetSource" + } + } + }, + "description": "A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server." + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + } + }, + "description": "Asset posted", + "headers": { + "Location": { + "description": "Asset location", + "schema": { + "format": "url", + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-length", + "message": "Invalid content length" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-length" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid content length (label: `invalid-length`)" + }, + "413": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 413, + "label": "client-error", + "message": "Asset too large" + }, + "properties": { + "code": { + "enum": [ + 413 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Asset too large (label: `client-error`)" + } + }, + "summary": "Upload an asset" + } + }, + "/provider/assets/{key}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Asset deleted" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unauthorised", + "message": "Unauthorised operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorised" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unauthorised operation (label: `unauthorised`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`key` not found\n\nAsset not found (label: `not-found`)" + } + }, + "summary": "Delete an asset" + }, + "get": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Asset-Token", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "asset_token", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Asset found", + "headers": { + "Location": { + "description": "Asset location", + "schema": { + "format": "url", + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`key` or Asset not found (label: `not-found`)" + } + }, + "summary": "Download an asset" + } + }, + "/provider/email": { + "put": { + "description": " [internal route ID: \"provider-update-email\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/EmailUpdate" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-email", + "message": "Invalid e-mail address." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-provider", + "message": "The provider does not exist." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-provider", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)" + }, + "429": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 429, + "label": "too-many-requests", + "message": "Too many request to generate a verification code." + }, + "properties": { + "code": { + "enum": [ + 429 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-requests" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Too many request to generate a verification code. (label: `too-many-requests`)" + } + }, + "summary": "Update a provider email" + } + }, + "/provider/login": { + "post": { + "description": " [internal route ID: \"provider-login\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ProviderLogin" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Set-Cookie": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Login as a provider" + } + }, + "/provider/password": { + "put": { + "description": " [internal route ID: \"provider-update-password\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PasswordChange" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "password-must-differ", + "message": "For password reset, new and old password must be different." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-must-differ" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "For password reset, new and old password must be different. (label: `password-must-differ`)" + } + }, + "summary": "Update a provider password" + } + }, + "/provider/password-reset": { + "post": { + "description": " [internal route ID: \"provider-password-reset\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PasswordReset" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-code", + "message": "Invalid password reset code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code", + "invalid-key" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)\n\nInvalid email or mobile number for password reset. (label: `invalid-key`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "password-must-differ", + "message": "For password reset, new and old password must be different." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-must-differ", + "code-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "For password reset, new and old password must be different. (label: `password-must-differ`)\n\nA password reset is already in progress. (label: `code-exists`)\n\nA password reset is already in progress. (label: `code-exists`)" + }, + "429": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 429, + "label": "too-many-requests", + "message": "Too many request to generate a verification code." + }, + "properties": { + "code": { + "enum": [ + 429 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-requests" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Too many request to generate a verification code. (label: `too-many-requests`)" + } + }, + "summary": "Begin a password reset" + } + }, + "/provider/password-reset/complete": { + "post": { + "description": " [internal route ID: \"provider-password-reset-complete\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CompletePasswordReset" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-code", + "message": "Invalid password reset code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "invalid-code", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nInvalid verification code (label: `invalid-code`)\n\nAccess denied. (label: `access-denied`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "password-must-differ", + "message": "For password reset, new and old password must be different." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-must-differ" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "For password reset, new and old password must be different. (label: `password-must-differ`)" + } + }, + "summary": "Complete a password reset" + } + }, + "/provider/register": { + "post": { + "description": " [internal route ID: \"provider-register\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewProvider" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewProviderResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewProviderResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-email", + "message": "Invalid e-mail address." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "429": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 429, + "label": "too-many-requests", + "message": "Too many request to generate a verification code." + }, + "properties": { + "code": { + "enum": [ + 429 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-requests" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Too many request to generate a verification code. (label: `too-many-requests`)" + } + }, + "summary": "Register a new provider" + } + }, + "/provider/services": { + "get": { + "description": " [internal route ID: \"get-provider-services\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/Service" + }, + "type": "array" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "List provider services" + }, + "post": { + "description": " [internal route ID: \"post-provider-services\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewService" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewServiceResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewServiceResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-service-key", + "message": "Invalid service key." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-service-key" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid service key. (label: `invalid-service-key`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "Create a new service" + } + }, + "/provider/services/{service-id}": { + "delete": { + "description": " [internal route ID: \"delete-provider-services-by-service-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "service-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeleteService" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Service not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`service-id` not found\n\nService not found. (label: `not-found`)" + } + }, + "summary": "Delete service" + }, + "get": { + "description": " [internal route ID: \"get-provider-services-by-service-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "service-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Service" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Service not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`service-id` not found\n\nService not found. (label: `not-found`)" + } + }, + "summary": "Get provider service by service id" + }, + "put": { + "description": " [internal route ID: \"put-provider-services-by-service-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "service-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UpdateService" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Provider service updated" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Provider not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`service-id` not found\n\nProvider not found. (label: `not-found`)\n\nService not found. (label: `not-found`)" + } + }, + "summary": "Update provider service" + } + }, + "/provider/services/{service-id}/connection": { + "put": { + "description": " [internal route ID: \"put-provider-services-connection-by-service-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "service-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UpdateServiceConn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Provider service connection updated" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-service-key", + "message": "Invalid service key." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-service-key" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid service key. (label: `invalid-service-key`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Service not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`service-id` not found\n\nService not found. (label: `not-found`)" + } + }, + "summary": "Update provider service connection" + } + }, + "/providers/{pid}": { + "get": { + "description": " [internal route ID: \"provider-get-profile\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "pid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Provider" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Provider" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Provider not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Provider not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`pid` or Provider not found. (label: `not-found`)" + } + }, + "summary": "Get profile" + } + }, + "/providers/{provider-id}/services": { + "get": { + "description": " [internal route ID: \"get-provider-services-by-provider-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "provider-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/ServiceProfile" + }, + "type": "array" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "Get provider services by provider id" + } + }, + "/providers/{provider-id}/services/{service-id}": { + "get": { + "description": " [internal route ID: \"get-provider-services-by-provider-id-and-service-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "provider-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "service-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ServiceProfile" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Service not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`provider-id` or `service-id` not found\n\nService not found. (label: `not-found`)" + } + }, + "summary": "Get provider service by provider id and service id" + } + }, + "/proxy/giphy/v1/gifs": {}, + "/proxy/googlemaps/api/staticmap": {}, + "/proxy/googlemaps/maps/api/geocode": {}, + "/proxy/youtube/v3": {}, + "/push/tokens": { + "get": { + "description": " [internal route ID: \"get-push-tokens\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PushTokenList" + } + } + }, + "description": "" + } + }, + "summary": "List the user's registered push tokens" + }, + "post": { + "description": " [internal route ID: \"register-push-token\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PushToken" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PushToken" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PushToken" + } + } + }, + "description": "Push token registered", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 400, + "label": "apns-voip-not-supported", + "message": "Adding APNS_VOIP tokens is not supported" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "apns-voip-not-supported" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "apns-voip-not-supported", + "message": "Adding APNS_VOIP tokens is not supported" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "apns-voip-not-supported" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Adding APNS_VOIP tokens is not supported (label: `apns-voip-not-supported`) or `body`" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "app-not-found", + "message": "App does not exist" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "app-not-found", + "invalid-token" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "app-not-found", + "message": "App does not exist" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "app-not-found", + "invalid-token" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "App does not exist (label: `app-not-found`)\n\nInvalid push token (label: `invalid-token`)" + }, + "413": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 413, + "label": "sns-thread-budget-reached", + "message": "Too many concurrent calls to SNS; is SNS down?" + }, + "properties": { + "code": { + "enum": [ + 413 + ], + "type": "integer" + }, + "label": { + "enum": [ + "sns-thread-budget-reached", + "token-too-long", + "metadata-too-long" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 413, + "label": "sns-thread-budget-reached", + "message": "Too many concurrent calls to SNS; is SNS down?" + }, + "properties": { + "code": { + "enum": [ + 413 + ], + "type": "integer" + }, + "label": { + "enum": [ + "sns-thread-budget-reached", + "token-too-long", + "metadata-too-long" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Too many concurrent calls to SNS; is SNS down? (label: `sns-thread-budget-reached`)\n\nPush token length must be < 8192 for GCM or 400 for APNS (label: `token-too-long`)\n\nTried to add token to endpoint resulting in metadata length > 2048 (label: `metadata-too-long`)" + } + }, + "summary": "Register a native push token" + } + }, + "/push/tokens/{pid}": { + "delete": { + "description": " [internal route ID: \"delete-push-token\"]\n\n", + "parameters": [ + { + "description": "The push token to delete", + "in": "path", + "name": "pid", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Push token unregistered" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Push token not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Push token not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`pid` or Push token not found (label: `not-found`)" + } + }, + "summary": "Unregister a native push token" + } + }, + "/register": { + "post": { + "description": " [internal route ID: \"register\"]\n\nIf the environment where the registration takes place is private and a registered email address is not whitelisted, a 403 error is returned.Calls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + }, + "description": "User created and pending activation", + "headers": { + "Location": { + "description": "UserId", + "schema": { + "format": "uuid", + "type": "string" + } + }, + "Set-Cookie": { + "description": "Cookie", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 400, + "label": "invalid-invitation-code", + "message": "Invalid invitation code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-invitation-code", + "invalid-email", + "invalid-phone" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-invitation-code", + "message": "Invalid invitation code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-invitation-code", + "invalid-email", + "invalid-phone" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid invitation code. (label: `invalid-invitation-code`)\n\nInvalid e-mail address. (label: `invalid-email`)\n\nInvalid mobile phone number (label: `invalid-phone`) or `body`" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "unauthorized", + "message": "Unauthorized e-mail address" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorized", + "missing-identity", + "blacklisted-email", + "too-many-team-members", + "user-creation-restricted" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unauthorized", + "message": "Unauthorized e-mail address" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorized", + "missing-identity", + "blacklisted-email", + "too-many-team-members", + "user-creation-restricted" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unauthorized e-mail address (label: `unauthorized`)\n\nUsing an invitation code requires registering the given email. (label: `missing-identity`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nToo many members in this team. (label: `too-many-team-members`)\n\nThis instance does not allow creation of personal users or teams. (label: `user-creation-restricted`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "invalid-code", + "message": "User does not exist" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "invalid-code", + "message": "User does not exist" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "User does not exist (label: `invalid-code`)\n\nInvalid activation code (label: `invalid-code`)" + }, + "409": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The given e-mail address is in use. (label: `key-exists`)" + } + }, + "summary": "Register a new user." + } + }, + "/scim/auth-tokens": { + "delete": { + "parameters": [ + { + "in": "query", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "password-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nPassword authentication failed. (label: `password-authentication-failed`)" + } + } + }, + "get": { + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ScimTokenList" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "password-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nPassword authentication failed. (label: `password-authentication-failed`)" + } + } + }, + "post": { + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CreateScimToken" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CreateScimTokenResponse" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "password-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nPassword authentication failed. (label: `password-authentication-failed`)" + } + } + } + }, + "/search/contacts": { + "get": { + "description": " [internal route ID: \"search-contacts\"]\n\nCalls federation service brig on search-users
Calls federation service brig on get-users-by-ids", + "parameters": [ + { + "description": "Search query", + "in": "query", + "name": "q", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Searched domain. Note: This is optional only for backwards compatibility, future versions will mandate this.", + "in": "query", + "name": "domain", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Number of results to return (min: 1, max: 500, default 15)", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int32", + "maximum": 500, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchResult" + } + } + }, + "description": "" + } + }, + "summary": "Search for users" + } + }, + "/self": { + "delete": { + "description": " [internal route ID: \"delete-self\"]\n\nif the account has a verified identity, a verification code is sent and needs to be confirmed to authorise the deletion. if the account has no verified identity but a password, it must be provided. if password is correct, or if neither a verified identity nor a password exists, account deletion is scheduled immediately.Calls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeleteUser" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Deletion is initiated." + }, + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletionCodeTimeout" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeletionCodeTimeout" + } + } + }, + "description": "Deletion is pending verification with a code." + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-user", + "message": "Invalid user" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-user" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid user (label: `invalid-user`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-self-delete-for-team-owner", + "message": "Team owners are not allowed to delete themselves; ask a fellow owner" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-self-delete-for-team-owner", + "pending-delete", + "missing-auth", + "invalid-credentials", + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Team owners are not allowed to delete themselves; ask a fellow owner (label: `no-self-delete-for-team-owner`)\n\nA verification code for account deletion is still pending (label: `pending-delete`)\n\nRe-authentication via password required (label: `missing-auth`)\n\nAuthentication failed (label: `invalid-credentials`)\n\nInvalid verification code (label: `invalid-code`)" + } + }, + "summary": "Initiate account deletion." + }, + "get": { + "description": " [internal route ID: \"get-self\"]\n\n\nOAuth scope: `read:self`", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + }, + "description": "" + } + }, + "summary": "Get your own profile" + }, + "put": { + "description": " [internal route ID: \"put-self\"]\n\nCalls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User updated" + } + }, + "summary": "Update your profile." + } + }, + "/self/email": { + "delete": { + "description": " [internal route ID: \"remove-email\"]\n\nYour email address can only be removed if you also have a phone number.Calls federation service brig on send-connection-action", + "responses": { + "200": { + "description": "Identity Removed" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "last-identity", + "message": "The last user identity cannot be removed." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "last-identity", + "no-password", + "no-identity" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "last-identity", + "message": "The last user identity cannot be removed." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "last-identity", + "no-password", + "no-identity" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The last user identity cannot be removed. (label: `last-identity`)\n\nThe user has no password. (label: `no-password`)\n\nThe user has no verified email (label: `no-identity`)" + } + }, + "summary": "Remove your email address." + } + }, + "/self/handle": { + "put": { + "description": " [internal route ID: \"change-handle\"]\n\nCalls federation service brig on send-connection-action
Calls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/HandleUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Handle Changed" + } + }, + "summary": "Change your handle." + } + }, + "/self/locale": { + "put": { + "description": " [internal route ID: \"change-locale\"]\n\nCalls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/LocaleUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Local Changed" + } + }, + "summary": "Change your locale." + } + }, + "/self/password": { + "head": { + "description": " [internal route ID: \"check-password-exists\"]\n\n", + "responses": { + "200": { + "description": "Password is set" + }, + "404": { + "description": "Password is not set" + } + }, + "summary": "Check that your password is set." + }, + "put": { + "description": " [internal route ID: \"change-password\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PasswordChange" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Password Changed" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "no-identity" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "no-identity" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nThe user has no verified email (label: `no-identity`)" + }, + "409": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 409, + "label": "password-must-differ", + "message": "For password change, new and old password must be different." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-must-differ" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "password-must-differ", + "message": "For password change, new and old password must be different." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-must-differ" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "For password change, new and old password must be different. (label: `password-must-differ`)" + } + }, + "summary": "Change your password." + } + }, + "/self/supported-protocols": { + "put": { + "description": " [internal route ID: \"change-supported-protocols\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SupportedProtocolUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Supported protocols changed" + } + }, + "summary": "Change your supported protocols" + } + }, + "/services": { + "get": { + "description": " [internal route ID: \"get-services\"]\n\n", + "parameters": [ + { + "in": "query", + "name": "tags", + "required": false, + "schema": { + "enum": [ + "audio", + "books", + "business", + "design", + "education", + "entertainment", + "finance", + "fitness", + "food-drink", + "games", + "graphics", + "health", + "integration", + "lifestyle", + "media", + "medical", + "movies", + "music", + "news", + "photography", + "poll", + "productivity", + "quiz", + "rating", + "shopping", + "social", + "sports", + "travel", + "tutorial", + "video", + "weather" + ], + "type": "string" + } + }, + { + "in": "query", + "name": "start", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int32", + "maximum": 100, + "minimum": 10, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ServiceProfile" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "List services" + } + }, + "/sso/finalize-login": { + "post": { + "deprecated": true, + "description": "DEPRECATED! use /sso/metadata/:tid instead! Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams", + "responses": { + "200": { + "content": { + "text/plain;charset=utf-8": { + "schema": { + "type": "string" + } + } + }, + "description": "" + } + } + } + }, + "/sso/finalize-login/{team}": { + "post": { + "parameters": [ + { + "in": "path", + "name": "team", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain;charset=utf-8": { + "schema": { + "type": "string" + } + } + }, + "description": "" + } + } + } + }, + "/sso/initiate-login/{idp}": { + "get": { + "parameters": [ + { + "in": "query", + "name": "success_redirect", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "error_redirect", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "idp", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "$ref": "#/components/schemas/FormRedirect" + } + } + }, + "description": "" + } + } + }, + "head": { + "parameters": [ + { + "in": "query", + "name": "success_redirect", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "error_redirect", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "idp", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain;charset=utf-8": {} + }, + "description": "" + } + } + } + }, + "/sso/metadata": { + "get": { + "deprecated": true, + "description": "DEPRECATED! use /sso/metadata/:tid instead! Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams", + "responses": { + "200": { + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + }, + "description": "" + } + } + } + }, + "/sso/metadata/{team}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "team", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + }, + "description": "" + } + } + } + }, + "/sso/settings": { + "get": { + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SsoSettings" + } + } + }, + "description": "" + } + } + } + }, + "/system/settings": { + "get": { + "description": " [internal route ID: \"get-system-settings\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SystemSettings" + } + } + }, + "description": "" + } + }, + "summary": "Returns a curated set of system configuration settings for authorized users." + } + }, + "/system/settings/unauthorized": { + "get": { + "description": " [internal route ID: \"get-system-settings-unauthorized\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SystemSettingsPublic" + } + } + }, + "description": "" + } + }, + "summary": "Returns a curated set of system configuration settings." + } + }, + "/teams/invitations/by-email": { + "head": { + "description": " [internal route ID: \"head-team-invitations\"]\n\n", + "parameters": [ + { + "description": "Email address", + "in": "query", + "name": "email", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Pending invitation exists." + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "No pending invitations exists." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "No pending invitations exists." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "No pending invitations exists. (label: `not-found`)" + }, + "409": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 409, + "label": "conflicting-invitations", + "message": "Multiple conflicting invitations to different teams exists." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "conflicting-invitations" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "conflicting-invitations", + "message": "Multiple conflicting invitations to different teams exists." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "conflicting-invitations" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Multiple conflicting invitations to different teams exists. (label: `conflicting-invitations`)" + } + }, + "summary": "Check if there is an invitation pending given an email address." + } + }, + "/teams/invitations/info": { + "get": { + "description": " [internal route ID: \"get-team-invitation-info\"]\n\n", + "parameters": [ + { + "description": "Invitation code", + "in": "query", + "name": "code", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + } + }, + "description": "Invitation info" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-invitation-code", + "message": "Invalid invitation code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-invitation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `code`\n\nInvalid invitation code. (label: `invalid-invitation-code`)" + } + }, + "summary": "Get invitation info given a code." + } + }, + "/teams/notifications": { + "get": { + "description": " [internal route ID: \"get-team-notifications\"]\n\nThis is a work-around for scalability issues with gundeck user event fan-out. It does not track all team-wide events, but only `member-join`.\nNote that `/teams/notifications` behaves differently from `/notifications`:\n- If there is a gap between the notification id requested with `since` and the available data, team queues respond with 200 and the data that could be found. They do NOT respond with status 404, but valid data in the body.\n- The notification with the id given via `since` is included in the response if it exists. You should remove this and only use it to decide whether there was a gap between your last request and this one.\n- If the notification id does *not* exist, you get the more recent events from the queue (instead of all of them). This can be done because a notification id is a UUIDv1, which is essentially a time stamp.\n- There is no corresponding `/last` end-point to get only the most recent event. That end-point was only useful to avoid having to pull the entire queue. In team queues, if you have never requested the queue before and have no prior notification id, just pull with timestamp 'now'.", + "parameters": [ + { + "description": "Notification id to start with in the response (UUIDv1)", + "in": "query", + "name": "since", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Maximum number of events to return (1..10000; default: 1000)", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int32", + "maximum": 10000, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/QueuedNotificationList" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-notification-id", + "message": "Could not parse notification id (must be UUIDv1)." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-notification-id" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `size` or `since`\n\nCould not parse notification id (must be UUIDv1). (label: `invalid-notification-id`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Team not found (label: `no-team`)" + } + }, + "summary": "Read recently added team members from team queue" + } + }, + "/teams/{team-id}/services/whitelist": { + "post": { + "description": " [internal route ID: \"post-team-whitelist-by-team-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "team-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UpdateServiceWhitelist" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "UpdateServiceWhitelistRespChanged" + }, + "204": { + "description": "UpdateServiceWhitelistRespUnchanged" + } + }, + "summary": "Update service whitelist" + } + }, + "/teams/{team-id}/services/whitelisted": { + "get": { + "description": " [internal route ID: \"get-whitelisted-services-by-team-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "team-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "prefix", + "required": false, + "schema": { + "maxLength": 1, + "minLength": 128, + "type": "string" + } + }, + { + "in": "query", + "name": "filter_disabled", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int32", + "maximum": 100, + "minimum": 10, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ServiceProfile" + } + } + }, + "description": "" + } + }, + "summary": "Get whitelisted services by team id" + } + }, + "/teams/{tid}": { + "delete": { + "description": " [internal route ID: \"delete-team\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamDeleteData" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Team is scheduled for removal" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Verification code required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "access-denied", + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Verification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (missing DeleteTeam) (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + }, + "503": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 503, + "label": "queue-full", + "message": "The delete queue is full; no further delete requests can be processed at the moment" + }, + "properties": { + "code": { + "enum": [ + 503 + ], + "type": "integer" + }, + "label": { + "enum": [ + "queue-full" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The delete queue is full; no further delete requests can be processed at the moment (label: `queue-full`)" + } + }, + "summary": "Delete a team" + }, + "get": { + "description": " [internal route ID: \"get-team\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get a team by ID" + }, + "put": { + "description": " [internal route ID: \"update-team\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamUpdateData" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Team updated" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions (missing SetTeamData)" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (missing SetTeamData) (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)" + } + }, + "summary": "Update team properties" + } + }, + "/teams/{tid}/conversations": { + "get": { + "description": " [internal route ID: \"get-team-conversations\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamConversationList" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + } + }, + "summary": "Get team conversations" + } + }, + "/teams/{tid}/conversations/roles": { + "get": { + "description": " [internal route ID: \"get-team-conversation-roles\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationRolesList" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)" + } + }, + "summary": "Get existing roles available for the given team" + } + }, + "/teams/{tid}/conversations/{cid}": { + "delete": { + "description": " [internal route ID: \"delete-team-conversation\"]\n\nCalls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "cid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Conversation deleted" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing delete_conversation) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `cid` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Remove a team conversation" + }, + "get": { + "description": " [internal route ID: \"get-team-conversation\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "cid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamConversation" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `cid` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Get one team conversation" + } + }, + "/teams/{tid}/features": { + "get": { + "description": " [internal route ID: \"get-all-feature-configs-for-team\"]\n\nGets feature configs for a team. User must be a member of the team and have permission to view team features.", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AllTeamFeatures" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Gets feature configs for a team" + } + }, + "/teams/{tid}/features/appLock": { + "get": { + "description": " [internal route ID: (\"get\", AppLockConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AppLockConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for appLock" + }, + "put": { + "description": " [internal route ID: (\"put\", AppLockConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AppLockConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AppLockConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for appLock" + } + }, + "/teams/{tid}/features/classifiedDomains": { + "get": { + "description": " [internal route ID: (\"get\", ClassifiedDomainsConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClassifiedDomainsConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for classifiedDomains" + } + }, + "/teams/{tid}/features/conferenceCalling": { + "get": { + "description": " [internal route ID: (\"get\", ConferenceCallingConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConferenceCallingConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for conferenceCalling" + }, + "put": { + "description": " [internal route ID: (\"put\", ConferenceCallingConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConferenceCallingConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConferenceCallingConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for conferenceCalling" + } + }, + "/teams/{tid}/features/conversationGuestLinks": { + "get": { + "description": " [internal route ID: (\"get\", GuestLinksConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/GuestLinksConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for conversationGuestLinks" + }, + "put": { + "description": " [internal route ID: (\"put\", GuestLinksConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/GuestLinksConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/GuestLinksConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for conversationGuestLinks" + } + }, + "/teams/{tid}/features/digitalSignatures": { + "get": { + "description": " [internal route ID: (\"get\", DigitalSignaturesConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DigitalSignaturesConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for digitalSignatures" + } + }, + "/teams/{tid}/features/enforceFileDownloadLocation": { + "get": { + "description": " [internal route ID: (\"get\", EnforceFileDownloadLocationConfig)]\n\n

Custom feature: only supported on some dedicated on-prem systems.

", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/EnforceFileDownloadLocation.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for enforceFileDownloadLocation" + }, + "put": { + "description": " [internal route ID: (\"put\", EnforceFileDownloadLocationConfig)]\n\n

Custom feature: only supported on some dedicated on-prem systems.

", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/EnforceFileDownloadLocation.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/EnforceFileDownloadLocation.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for enforceFileDownloadLocation" + } + }, + "/teams/{tid}/features/exposeInvitationURLsToTeamAdmin": { + "get": { + "description": " [internal route ID: (\"get\", ExposeInvitationURLsToTeamAdminConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ExposeInvitationURLsToTeamAdminConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for exposeInvitationURLsToTeamAdmin" + }, + "put": { + "description": " [internal route ID: (\"put\", ExposeInvitationURLsToTeamAdminConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ExposeInvitationURLsToTeamAdminConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ExposeInvitationURLsToTeamAdminConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for exposeInvitationURLsToTeamAdmin" + } + }, + "/teams/{tid}/features/fileSharing": { + "get": { + "description": " [internal route ID: (\"get\", FileSharingConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/FileSharingConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for fileSharing" + }, + "put": { + "description": " [internal route ID: (\"put\", FileSharingConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/FileSharingConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/FileSharingConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for fileSharing" + } + }, + "/teams/{tid}/features/legalhold": { + "get": { + "description": " [internal route ID: (\"get\", LegalholdConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/LegalholdConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for legalhold" + }, + "put": { + "description": " [internal route ID: (\"put\", LegalholdConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/LegalholdConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/LegalholdConfig.LockableFeature" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "legalhold-not-registered", + "message": "legal hold service has not been registered for this team" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-registered" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "legalhold-disable-unimplemented", + "message": "legal hold cannot be disabled for whitelisted teams" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-disable-unimplemented", + "legalhold-not-enabled", + "too-large-team-for-legalhold", + "action-denied", + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nCannot enable legalhold on large teams (reason: for removing LH from team, we need to iterate over all members, which is only supported for teams with less than 2k members) (label: `too-large-team-for-legalhold`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)" + } + }, + "summary": "Put config for legalhold" + } + }, + "/teams/{tid}/features/limitedEventFanout": { + "get": { + "description": " [internal route ID: (\"get\", LimitedEventFanoutConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/LimitedEventFanoutConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for limitedEventFanout" + } + }, + "/teams/{tid}/features/mls": { + "get": { + "description": " [internal route ID: (\"get\", MLSConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for mls" + }, + "put": { + "description": " [internal route ID: (\"put\", MLSConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for mls" + } + }, + "/teams/{tid}/features/mlsE2EId": { + "get": { + "description": " [internal route ID: (\"get\", MlsE2EIdConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MlsE2EIdConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for mlsE2EId" + }, + "put": { + "description": " [internal route ID: (\"put\", MlsE2EIdConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MlsE2EIdConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MlsE2EIdConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for mlsE2EId" + } + }, + "/teams/{tid}/features/mlsMigration": { + "get": { + "description": " [internal route ID: (\"get\", MlsMigrationConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MlsMigration.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for mlsMigration" + }, + "put": { + "description": " [internal route ID: (\"put\", MlsMigrationConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MlsMigration.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MlsMigration.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for mlsMigration" + } + }, + "/teams/{tid}/features/outlookCalIntegration": { + "get": { + "description": " [internal route ID: (\"get\", OutlookCalIntegrationConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OutlookCalIntegrationConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for outlookCalIntegration" + }, + "put": { + "description": " [internal route ID: (\"put\", OutlookCalIntegrationConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OutlookCalIntegrationConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OutlookCalIntegrationConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for outlookCalIntegration" + } + }, + "/teams/{tid}/features/searchVisibility": { + "get": { + "description": " [internal route ID: (\"get\", SearchVisibilityAvailableConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchVisibilityAvailableConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for searchVisibility" + }, + "put": { + "description": " [internal route ID: (\"put\", SearchVisibilityAvailableConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchVisibilityAvailableConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchVisibilityAvailableConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for searchVisibility" + } + }, + "/teams/{tid}/features/searchVisibilityInbound": { + "get": { + "description": " [internal route ID: (\"get\", SearchVisibilityInboundConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchVisibilityInboundConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for searchVisibilityInbound" + }, + "put": { + "description": " [internal route ID: (\"put\", SearchVisibilityInboundConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchVisibilityInboundConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchVisibilityInboundConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for searchVisibilityInbound" + } + }, + "/teams/{tid}/features/selfDeletingMessages": { + "get": { + "description": " [internal route ID: (\"get\", SelfDeletingMessagesConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SelfDeletingMessagesConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for selfDeletingMessages" + }, + "put": { + "description": " [internal route ID: (\"put\", SelfDeletingMessagesConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SelfDeletingMessagesConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SelfDeletingMessagesConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for selfDeletingMessages" + } + }, + "/teams/{tid}/features/sndFactorPasswordChallenge": { + "get": { + "description": " [internal route ID: (\"get\", SndFactorPasswordChallengeConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SndFactorPasswordChallengeConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for sndFactorPasswordChallenge" + }, + "put": { + "description": " [internal route ID: (\"put\", SndFactorPasswordChallengeConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SndFactorPasswordChallengeConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SndFactorPasswordChallengeConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for sndFactorPasswordChallenge" + } + }, + "/teams/{tid}/features/sso": { + "get": { + "description": " [internal route ID: (\"get\", SSOConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SSOConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for sso" + } + }, + "/teams/{tid}/features/validateSAMLemails": { + "get": { + "description": " [internal route ID: (\"get\", ValidateSAMLEmailsConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ValidateSAMLEmailsConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for validateSAMLemails" + } + }, + "/teams/{tid}/get-members-by-ids-using-post": { + "post": { + "description": " [internal route ID: \"get-team-members-by-ids\"]\n\nThe `has_more` field in the response body is always `false`.", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Maximum results to be returned", + "in": "query", + "name": "maxResults", + "required": false, + "schema": { + "format": "int32", + "maximum": 2000, + "minimum": 1, + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserIdList" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamMemberList" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "too-many-uids", + "message": "Can only process 2000 user ids per request." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-uids" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body` or `maxResults`\n\nCan only process 2000 user ids per request. (label: `too-many-uids`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)" + } + }, + "summary": "Get team members by user id list" + } + }, + "/teams/{tid}/invitations": { + "get": { + "description": " [internal route ID: \"get-team-invitations\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Invitation id to start from (ascending).", + "in": "query", + "name": "start", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Number of results to return (default 100, max 500).", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int32", + "maximum": 500, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationList" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/InvitationList" + } + } + }, + "description": "List of sent invitations" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient team permissions (label: `insufficient-permissions`)" + } + }, + "summary": "List the sent team invitations" + }, + "post": { + "description": " [internal route ID: \"send-team-invitation\"]\n\nInvitations are sent by email. The maximum allowed number of pending team invitations is equal to the team size.", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/InvitationRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + } + }, + "description": "Invitation was created and sent.", + "headers": { + "Location": { + "schema": { + "format": "url", + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-email", + "message": "Invalid e-mail address." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions", + "too-many-team-invitations", + "blacklisted-email", + "no-identity", + "no-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient team permissions (label: `insufficient-permissions`)\n\nToo many team invitations for this team (label: `too-many-team-invitations`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nThe user has no verified email (label: `no-identity`)\n\nThis operation requires the user to have a verified email address. (label: `no-email`)" + } + }, + "summary": "Create and send a new team invitation." + } + }, + "/teams/{tid}/invitations/{iid}": { + "delete": { + "description": " [internal route ID: \"delete-team-invitation\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "iid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Invitation deleted" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient team permissions (label: `insufficient-permissions`)" + } + }, + "summary": "Delete a pending team invitation by ID." + }, + "get": { + "description": " [internal route ID: \"get-team-invitation\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "iid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + } + }, + "description": "Invitation" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient team permissions (label: `insufficient-permissions`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Notification not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Notification not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `iid` or Notification not found. (label: `not-found`)" + } + }, + "summary": "Get a pending team invitation by ID." + } + }, + "/teams/{tid}/legalhold/consent": { + "post": { + "description": " [internal route ID: \"consent-to-legal-hold\"]\n\nCalls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Grant consent successful" + }, + "204": { + "description": "Consent already granted" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam member not found (label: `no-team-member`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)" + } + }, + "summary": "Consent to legal hold" + } + }, + "/teams/{tid}/legalhold/settings": { + "delete": { + "description": " [internal route ID: \"delete-legal-hold-settings\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to members with a legalhold client (via brig)\n- UserLegalHoldDisabled event to contacts of members with a legalhold client (via brig)
Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/RemoveLegalHoldSettingsRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Legal hold service settings deleted" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "legalhold-not-registered", + "message": "legal hold service has not been registered for this team" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-registered" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "legalhold-disable-unimplemented", + "message": "legal hold cannot be disabled for whitelisted teams" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-disable-unimplemented", + "legalhold-not-enabled", + "invalid-op", + "action-denied", + "no-team-member", + "operation-denied", + "code-authentication-required", + "code-authentication-failed", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)" + } + }, + "summary": "Delete legal hold service settings" + }, + "get": { + "description": " [internal route ID: \"get-legal-hold-settings\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ViewLegalHoldService" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)" + } + }, + "summary": "Get legal hold service settings" + }, + "post": { + "description": " [internal route ID: \"create-legal-hold-settings\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewLegalHoldService" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewLegalHoldService" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ViewLegalHoldService" + } + } + }, + "description": "Legal hold service settings created" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "legalhold-status-bad", + "message": "legal hold service: invalid response" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-status-bad", + "legalhold-invalid-key" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)\n\nlegal hold service pubkey is invalid (label: `legalhold-invalid-key`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "legalhold-not-enabled", + "message": "legal hold is not enabled for this team" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-enabled", + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)" + } + }, + "summary": "Create legal hold service settings" + } + }, + "/teams/{tid}/legalhold/{uid}": { + "delete": { + "description": " [internal route ID: \"disable-legal-hold-for-user\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to the user owning the client (via brig)\n- UserLegalHoldDisabled event to contacts of the user owning the client (via brig)
Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DisableLegalHoldForUserRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Disable legal hold successful" + }, + "204": { + "description": "Legal hold was not enabled" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "legalhold-not-registered", + "message": "legal hold service has not been registered for this team" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-registered" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member", + "action-denied", + "code-authentication-required", + "code-authentication-failed", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)" + } + }, + "summary": "Disable legal hold for user" + }, + "get": { + "description": " [internal route ID: \"get-legal-hold\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserLegalHoldStatusResponse" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)" + } + }, + "summary": "Get legal hold status" + }, + "post": { + "description": " [internal route ID: \"request-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- LegalHoldClientRequested event to contacts of the user the device is requested for, if they didn't already have a legalhold client (via brig)
Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Request device successful" + }, + "204": { + "description": "Request device already pending" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "legalhold-not-registered", + "message": "legal hold service has not been registered for this team" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-registered", + "legalhold-status-bad" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold service has not been registered for this team (label: `legalhold-not-registered`)\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "legalhold-not-enabled", + "message": "legal hold is not enabled for this team" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-enabled", + "operation-denied", + "no-team-member", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "legalhold-no-consent", + "message": "user has not given consent to using legal hold" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-no-consent", + "legalhold-already-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "user has not given consent to using legal hold (label: `legalhold-no-consent`)\n\nlegal hold is already enabled for this user (label: `legalhold-already-enabled`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "legalhold-illegal-op", + "message": "internal server error: inconsistent change of user's legalhold state" + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-illegal-op", + "legalhold-internal" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "internal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)\n\nlegal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)" + } + }, + "summary": "Request legal hold device" + } + }, + "/teams/{tid}/legalhold/{uid}/approve": { + "put": { + "description": " [internal route ID: \"approve-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientAdded event to the user owning the client (via brig)\n- UserLegalHoldEnabled event to contacts of the user owning the client (via brig)\n- ClientRemoved event to the user, if removing old client due to max number (via brig)
Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ApproveLegalHoldForUserRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Legal hold approved" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "legalhold-not-registered", + "message": "legal hold service has not been registered for this team" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-registered" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "legalhold-not-enabled", + "message": "legal hold is not enabled for this team" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-enabled", + "no-team-member", + "action-denied", + "access-denied", + "code-authentication-required", + "code-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "legalhold-no-device-allocated", + "message": "no legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-no-device-allocated" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `uid` not found\n\nno legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow. (label: `legalhold-no-device-allocated`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "legalhold-already-enabled", + "message": "legal hold is already enabled for this user" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-already-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold is already enabled for this user (label: `legalhold-already-enabled`)" + }, + "412": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 412, + "label": "legalhold-not-pending", + "message": "legal hold cannot be approved without being in a pending state" + }, + "properties": { + "code": { + "enum": [ + 412 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-pending" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold cannot be approved without being in a pending state (label: `legalhold-not-pending`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)" + } + }, + "summary": "Approve legal hold device" + } + }, + "/teams/{tid}/members": { + "get": { + "description": " [internal route ID: \"get-team-members\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Maximum results to be returned", + "in": "query", + "name": "maxResults", + "required": false, + "schema": { + "format": "int32", + "maximum": 2000, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Optional, when not specified, the first page will be returned.Every returned page contains a `pagingState`, this should be supplied to retrieve the next page.", + "in": "query", + "name": "pagingState", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamMembersPage" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)" + } + }, + "summary": "Get team members" + }, + "put": { + "description": " [internal route ID: \"update-team-member\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewTeamMember" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member", + "too-many-team-admins", + "invalid-permissions", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nMaximum number of admins per team reached (label: `too-many-team-admins`)\n\nThe specified permissions are invalid (label: `invalid-permissions`)\n\nYou do not have permission to access this resource (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam member not found (label: `no-team-member`)\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Update an existing team member" + } + }, + "/teams/{tid}/members/csv": { + "get": { + "description": " [internal route ID: \"get-team-members-csv\"]\n\nThe endpoint returns data in chunked transfer encoding. Internal server errors might result in a failed transfer instead of a 500 response.", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/csv": {} + }, + "description": "CSV of team members" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "You do not have permission to access this resource" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "You do not have permission to access this resource (label: `access-denied`)" + } + }, + "summary": "Get all members of the team as a CSV file" + } + }, + "/teams/{tid}/members/{uid}": { + "delete": { + "description": " [internal route ID: \"delete-team-member\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamMemberDeleteData" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "202": { + "description": "Team member scheduled for deletion" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member", + "access-denied", + "code-authentication-required", + "code-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `uid` not found\n\nTeam not found (label: `no-team`)\n\nTeam member not found (label: `no-team-member`)" + } + }, + "summary": "Remove an existing team member" + }, + "get": { + "description": " [internal route ID: \"get-team-member\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamMember" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)" + } + }, + "summary": "Get single team member" + } + }, + "/teams/{tid}/search": { + "get": { + "description": " [internal route ID: \"browse-team\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Search expression", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Role filter, eg. `member,partner`. Empty list means do not filter.", + "in": "query", + "name": "frole", + "required": false, + "schema": { + "items": { + "enum": [ + "owner", + "admin", + "member", + "partner" + ], + "type": "string" + }, + "type": "array" + } + }, + { + "description": "Can be one of name, handle, email, saml_idp, managed_by, role, created_at.", + "in": "query", + "name": "sortby", + "required": false, + "schema": { + "enum": [ + "name", + "handle", + "email", + "saml_idp", + "managed_by", + "role", + "created_at" + ], + "type": "string" + } + }, + { + "description": "Can be one of asc, desc.", + "in": "query", + "name": "sortorder", + "required": false, + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "description": "Number of results to return (min: 1, max: 500, default: 15)", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int32", + "maximum": 500, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Optional, when not specified, the first page will be returned. Every returned page contains a `paging_state`, this should be supplied to retrieve the next page.", + "in": "query", + "name": "pagingState", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResult" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchResult" + } + } + }, + "description": "Search results" + } + }, + "summary": "Browse team for members (requires add-user permission)" + } + }, + "/teams/{tid}/search-visibility": { + "get": { + "description": " [internal route ID: \"get-search-visibility\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamSearchVisibilityView" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)" + } + }, + "summary": "Shows the value for search visibility" + }, + "put": { + "description": " [internal route ID: \"set-search-visibility\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamSearchVisibilityView" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Search visibility set" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "team-search-visibility-not-enabled", + "message": "Custom search is not available for this team" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "team-search-visibility-not-enabled", + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Custom search is not available for this team (label: `team-search-visibility-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Sets the search visibility for the whole team" + } + }, + "/teams/{tid}/size": { + "get": { + "description": " [internal route ID: \"get-team-size\"]\n\nCan be out of sync by roughly the `refresh_interval` of the ES index.", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamSize" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamSize" + } + } + }, + "description": "Number of team members" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-invitation-code", + "message": "Invalid invitation code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-invitation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid invitation code. (label: `invalid-invitation-code`)" + } + }, + "summary": "Get the number of team members as an integer" + } + }, + "/users/handles": { + "post": { + "description": " [internal route ID: \"check-user-handles\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CheckHandles" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Handle" + }, + "type": "array" + } + }, + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/Handle" + }, + "type": "array" + } + } + }, + "description": "List of free handles" + } + }, + "summary": "Check availability of user handles" + } + }, + "/users/handles/{handle}": { + "head": { + "description": " [internal route ID: \"check-user-handle\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "handle", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + } + }, + "description": "Handle is taken" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-handle", + "message": "The given handle is invalid (less than 2 or more than 256 characters; chars not in \"a-z0-9_.-\"; or on the blocklist)" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-handle" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The given handle is invalid (less than 2 or more than 256 characters; chars not in \"a-z0-9_.-\"; or on the blocklist) (label: `invalid-handle`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Handle not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`handle` not found\n\nHandle not found (label: `not-found`)" + } + }, + "summary": "Check whether a user handle can be taken" + } + }, + "/users/list-clients": { + "post": { + "description": " [internal route ID: \"list-clients-bulk@v2\"]\n\nIf a backend is unreachable, the clients from that backend will be omitted from the responseCalls federation service brig on get-user-clients", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/LimitedQualifiedUserIdList_500" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "qualified_user_map": { + "$ref": "#/components/schemas/QualifiedUserMap_Set_PubClient" + } + }, + "type": "object" + } + } + }, + "description": "" + } + }, + "summary": "List all clients for a set of user ids" + } + }, + "/users/list-prekeys": { + "post": { + "description": " [internal route ID: \"get-multi-user-prekey-bundle-qualified\"]\n\nYou can't request information for more users than maximum conversation size.Calls federation service brig on claim-multi-prekey-bundle", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/QualifiedUserClients" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/QualifiedUserClientPrekeyMapV4" + } + } + }, + "description": "" + } + }, + "summary": "(deprecated) Given a map of user IDs to client IDs return a prekey for each one." + } + }, + "/users/{uid_domain}/{uid}": { + "get": { + "description": " [internal route ID: \"get-user-qualified\"]\n\nCalls federation service brig on get-users-by-ids", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserProfile" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserProfile" + } + } + }, + "description": "User found" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "User not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "User not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`uid_domain` or `uid` or User not found (label: `not-found`)" + } + }, + "summary": "Get a user by Domain and UserId" + } + }, + "/users/{uid_domain}/{uid}/clients": { + "get": { + "description": " [internal route ID: \"get-user-clients-qualified\"]\n\nCalls federation service brig on get-user-clients", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/PubClient" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "summary": "Get all of a user's clients" + } + }, + "/users/{uid_domain}/{uid}/clients/{client}": { + "get": { + "description": " [internal route ID: \"get-user-client-qualified\"]\n\nCalls federation service brig on get-user-clients", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PubClient" + } + } + }, + "description": "" + } + }, + "summary": "Get a specific client of a user" + } + }, + "/users/{uid_domain}/{uid}/prekeys": { + "get": { + "description": " [internal route ID: \"get-users-prekey-bundle-qualified\"]\n\nCalls federation service brig on claim-prekey-bundle", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PrekeyBundle" + } + } + }, + "description": "" + } + }, + "summary": "Get a prekey for each client of a user." + } + }, + "/users/{uid_domain}/{uid}/prekeys/{client}": { + "get": { + "description": " [internal route ID: \"get-users-prekeys-client-qualified\"]\n\nCalls federation service brig on claim-prekey", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientPrekey" + } + } + }, + "description": "" + } + }, + "summary": "Get a prekey for a specific client of a user." + } + }, + "/users/{uid_domain}/{uid}/supported-protocols": { + "get": { + "description": " [internal route ID: \"get-supported-protocols\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/BaseProtocol" + }, + "type": "array", + "uniqueItems": true + } + }, + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/BaseProtocol" + }, + "type": "array", + "uniqueItems": true + } + } + }, + "description": "Protocols supported by the user" + } + }, + "summary": "Get a user's supported protocols" + } + }, + "/users/{uid}/email": { + "put": { + "description": " [internal route ID: \"update-user-email\"]\n\nIf the user has a pending email validation, the validation email will be resent.", + "parameters": [ + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/EmailUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + } + }, + "description": "" + } + }, + "summary": "Resend email address validation email." + } + }, + "/users/{uid}/rich-info": { + "get": { + "description": " [internal route ID: \"get-rich-info\"]\n\n", + "parameters": [ + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RichInfoAssocList" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/RichInfoAssocList" + } + } + }, + "description": "Rich info about the user" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient team permissions (label: `insufficient-permissions`)" + } + }, + "summary": "Get a user's rich info" + } + }, + "/verification-code/send": { + "post": { + "description": " [internal route ID: \"send-verification-code\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SendVerificationCode" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Verification code sent." + } + }, + "summary": "Send a verification code to a given email address." + } + } + }, + "security": [ + { + "ZAuth": [] + } + ], + "servers": [ + { + "url": "/v6" + } + ] +} From 60049d5892ac9249a47730e6e2cc19ad917c001e Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Thu, 27 Aug 2026 15:23:42 +0200 Subject: [PATCH 112/113] Revert "[WPB-28089] Treat team collaborators like team members in contact search. (#5452)" (#5484) This reverts commit 3309e7b8ecbbc70d987cea40a8e0162727598973. --- ...rators-like-team-members-in-contact-search | 1 - integration/test/Test/TeamCollaborators.hs | 106 -------- .../src/Wire/BrigAPIAccess/Local.hs | 65 ----- .../src/Wire/BrigAPIAccess/Rpc.hs | 234 ++++++++---------- .../IndexedUserStore/Bulk/ElasticSearch.hs | 22 +- .../Wire/IndexedUserStore/ElasticSearch.hs | 19 +- .../src/Wire/TeamCollaboratorsStore.hs | 2 - .../Wire/TeamCollaboratorsStore/Postgres.hs | 17 -- .../TeamCollaboratorsSubsystem/Interpreter.hs | 36 +-- .../src/Wire/UserSearch/Types.hs | 9 +- .../src/Wire/UserStore/IndexUser.hs | 8 +- .../src/Wire/UserSubsystem/Interpreter.hs | 20 +- .../test/unit/Wire/MiniBackend.hs | 9 +- .../test/unit/Wire/MockInterpreters.hs | 1 - .../Wire/MockInterpreters/BrigAPIAccess.hs | 80 ------ .../TeamCollaboratorsStore.hs | 2 - .../Wire/MockInterpreters/UserSubsystem.hs | 2 +- .../Wire/ScimSubsystem/InterpreterSpec.hs | 2 +- .../test/unit/Wire/UserSearch/TypesSpec.hs | 3 +- .../Wire/UserSubsystem/InterpreterSpec.hs | 2 +- libs/wire-subsystems/wire-subsystems.cabal | 2 - .../background-worker/src/Wire/Effects.hs | 2 +- services/brig/src/Brig/App.hs | 6 - .../brig/src/Brig/CanonicalInterpreter.hs | 28 +-- services/brig/src/Brig/Index/Eval.hs | 7 - services/brig/src/Brig/User/Search/Index.hs | 9 - services/galley/src/Galley/App.hs | 2 +- 27 files changed, 157 insertions(+), 539 deletions(-) delete mode 100644 changelog.d/1-api-changes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search delete mode 100644 libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs delete mode 100644 libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs diff --git a/changelog.d/1-api-changes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search b/changelog.d/1-api-changes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search deleted file mode 100644 index d66924aaeea..00000000000 --- a/changelog.d/1-api-changes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search +++ /dev/null @@ -1 +0,0 @@ -Treat team collaborators like team members in contact search. diff --git a/integration/test/Test/TeamCollaborators.hs b/integration/test/Test/TeamCollaborators.hs index 642dad537e8..cf55c3a558e 100644 --- a/integration/test/Test/TeamCollaborators.hs +++ b/integration/test/Test/TeamCollaborators.hs @@ -1,5 +1,3 @@ -{-# OPTIONS_GHC -Wno-ambiguous-fields #-} - -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2025 Wire Swiss GmbH @@ -19,9 +17,6 @@ module Test.TeamCollaborators where -import qualified API.Brig as BrigP -import qualified API.BrigInternal as BrigI -import API.Common (randomName) import API.Galley import qualified API.GalleyInternal as Internal import Data.Tuple.Extra @@ -322,104 +317,3 @@ testUpdateCollaborator = do [] >>= assertSuccess postOne2OneConversation bob alice team "chit-chat" >>= assertLabel 403 "operation-denied" - --- | Collaborators are part of the search space of the team they --- collaborate with: `GET /search/contacts` returns them to members of --- that team, just like it returns the team's own members. We test --- collaborators from other teams, personal user accounts that --- collaborate, and app. -testSearchFindsCollaborator :: (HasCallStack) => App () -testSearchFindsCollaborator = do - (owner, team, [alice]) <- createTeam OwnDomain 2 - (otherOwner, otherTeam, [bob, collab1]) <- createTeam OwnDomain 3 - collab2 :: Value <- randomUser OwnDomain def - collab3 :: Value <- - BrigP.createApp otherOwner otherTeam def - `bindResponse` \resp -> resp.json %. "user" - - collab1Name <- collab1 %. "name" & asString - collab2Name <- collab2 %. "name" & asString - collab3Name <- collab3 %. "name" & asString - - collab1Name' <- randomName - collab2Name' <- randomName - collab3Name' <- randomName - - -- Find before any collaborations have been established. - let assertFinds :: - (HasCallStack, MakesValue expectFound, MakesValue searcher) => - String -> - expectFound -> - searcher -> - App () - assertFinds searchTerm expectFound searcher = do - BrigI.refreshIndex OwnDomain - BrigP.searchContacts searcher searchTerm OwnDomain `bindResponse` \resp -> do - resp.status `shouldMatchInt` 200 - foundIds :: [String] <- resp.json %. "documents" >>= asList >>= mapM objId - expectedIds :: [String] <- (make >=> asList >=> mapM objId) expectFound - assertBool - ("found: " <> show foundIds <> "; expected: " <> show expectedIds) - (sort foundIds == sort expectedIds) - - for_ [owner, alice] $ assertFinds collab1Name ([] @Value) - for_ [otherOwner, bob] $ assertFinds collab1Name [collab1] - - for_ [owner, alice] $ assertFinds collab2Name [collab2] - for_ [otherOwner, bob] $ assertFinds collab2Name [collab2] - - for_ [owner, alice] $ assertFinds collab3Name ([] @Value) - for_ [otherOwner, bob] $ assertFinds collab3Name [collab3] - - -- Add collaborators to team - for_ [collab1, collab2, collab3] - $ \collab -> - addTeamCollaborator owner team collab ["implicit_connection"] >>= assertSuccess - - for_ [owner, alice] $ assertFinds collab1Name [collab1] - for_ [otherOwner, bob] $ assertFinds collab1Name [collab1] - - for_ [owner, alice] $ assertFinds collab2Name [collab2] - for_ [otherOwner, bob] $ assertFinds collab2Name [collab2] - - for_ [owner, alice] $ assertFinds collab3Name [collab3] - for_ [otherOwner, bob] $ assertFinds collab3Name [collab3] - - -- Check that updating name does not erase collaborating teams in index. - for_ [(collab1, collab1Name'), (collab2, collab2Name'), (collab3, collab3Name')] - $ \(collab, newName) -> do - let updateBody = (def :: BrigP.PutSelf) {BrigP.name = Just newName} - in BrigP.putSelf collab updateBody >>= assertSuccess - - for_ [owner, alice] $ assertFinds collab1Name' [collab1] - for_ [otherOwner, bob] $ assertFinds collab1Name' [collab1] - - for_ [owner, alice] $ assertFinds collab2Name' [collab2] - for_ [otherOwner, bob] $ assertFinds collab2Name' [collab2] - - for_ [owner, alice] $ assertFinds collab3Name' [collab3] - for_ [otherOwner, bob] $ assertFinds collab3Name' [collab3] - - -- Check that updating collaborating teams does not erase name in index. - for_ [collab1, collab2, collab3] - $ \collab -> do - removeTeamCollaborator owner team collab >>= assertSuccess - - for_ [owner, alice] $ assertFinds collab1Name' ([] @Value) - for_ [otherOwner, bob] $ assertFinds collab1Name' [collab1] - - for_ [owner, alice] $ assertFinds collab2Name' [collab2] - for_ [otherOwner, bob] $ assertFinds collab2Name' [collab2] - - for_ [owner, alice] $ assertFinds collab3Name' ([] @Value) - for_ [otherOwner, bob] $ assertFinds collab3Name' [collab3] - - -- Can one user collaborate in multiple teams without breaking search? - (_thirdOwner, _thirdTeam, [multiCollab]) <- createTeam OwnDomain 2 - multiCollabName <- multiCollab %. "name" & asString - - addTeamCollaborator owner team multiCollab ["implicit_connection"] >>= assertSuccess - addTeamCollaborator otherOwner otherTeam multiCollab ["implicit_connection"] >>= assertSuccess - - for_ [owner, alice] $ assertFinds multiCollabName [multiCollab] - for_ [otherOwner, bob] $ assertFinds multiCollabName [multiCollab] diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs deleted file mode 100644 index 43f04b0175c..00000000000 --- a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs +++ /dev/null @@ -1,65 +0,0 @@ --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2025 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - --- | Interprets 'BrigAPIAccess' from within brig itself, by calling into the local --- subsystems directly instead of round-tripping over HTTP to itself (as --- 'Wire.BrigAPIAccess.Rpc.interpretBrigAccess' does for every other service). --- --- Only the operations needed by code shared with other services (e.g. --- 'Wire.TeamCollaboratorsSubsystem') are implemented locally. Everything else --- falls back to the RPC handler, pointed at brig itself: correct, but a wasteful --- round-trip through our own listen socket, so it logs a warning and should be --- given a local implementation once something actually relies on it. -module Wire.BrigAPIAccess.Local where - -import Imports -import Polysemy -import Polysemy.Error (Error) -import Polysemy.Input (runInputConst) -import Polysemy.TinyLog (TinyLog) -import Polysemy.TinyLog qualified as Log -import System.Logger.Message qualified as Log -import Util.Options (Endpoint) -import Wire.BrigAPIAccess -import Wire.BrigAPIAccess.Rpc (brigAccessRpcHandler) -import Wire.ParseException (ParseException) -import Wire.Rpc (Rpc) -import Wire.RpcException (RpcException) -import Wire.UserSubsystem (UserSubsystem) -import Wire.UserSubsystem qualified as UserSubsystem - --- | The 'Endpoint' is brig's own; it is only used for the operations that have --- no local implementation yet. -interpretBrigAPIAccessLocally :: - forall r. - ( Member TinyLog r, - Member Rpc r, - Member (Error ParseException) r, - Member (Error RpcException) r - ) => - Endpoint -> - InterpreterFor UserSubsystem r -> - InterpreterFor BrigAPIAccess r -interpretBrigAPIAccessLocally selfEndpoint runUser = interpret $ \case - UpdateSearchIndex uid -> runUser (UserSubsystem.internalUpdateSearchIndex uid) - other -> selfRpc other - where - selfRpc :: forall m x. BrigAPIAccess m x -> Sem r x - selfRpc action = do - Log.warn $ - Log.msg (Log.val "BrigAPIAccess.Local: no local implementation, calling brig over HTTP") - runInputConst selfEndpoint (brigAccessRpcHandler action) diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs index b96d0abeea0..e42a4791392 100644 --- a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs @@ -82,131 +82,115 @@ interpretBrigAccess :: Sem (BrigAPIAccess ': r) a -> Sem r a interpretBrigAccess brigEndpoint = - interpret $ runInputConst brigEndpoint . brigAccessRpcHandler - --- | Handles a single 'BrigAPIAccess' action by calling brig over HTTP. --- --- Exposed separately from 'interpretBrigAccess' so that --- 'Wire.BrigAPIAccess.Local.interpretBrigAPIAccessLocally' can delegate the --- actions it does not implement itself. 'BrigAPIAccess' is a first-order --- effect, so @m@ is unconstrained and any handler's action can be passed here. -brigAccessRpcHandler :: - ( Member TinyLog r, - Member Rpc r, - Member (Error ParseException) r, - Member (Error RpcException) r, - Member (Input Endpoint) r - ) => - BrigAPIAccess m a -> - Sem r a -brigAccessRpcHandler = \case - GetConnectionsUnqualified uids muids mrel -> do - getConnectionsUnqualified uids muids mrel - GetConnections uids mquids mrel -> do - getConnections uids mquids mrel - PutConnectionInternal uc -> do - putConnectionInternal uc - ReauthUser uid reauth -> do - reAuthUser uid reauth - LookupActivatedUsers uids -> do - lookupActivatedUsers uids - GetUsers uids -> do - getUsers uids - DeleteUser uid -> do - deleteUser uid - GetContactList uid -> do - getContactList uid - GetUserExportData uid -> do - getUserExportData uid - GetSize tid -> do - getSize tid - LookupClients uids -> do - lookupClients uids - LookupClientsFull uids -> do - lookupClientsFull uids - NotifyClientsAboutLegalHoldRequest self other pk -> do - notifyClientsAboutLegalHoldRequest self other pk - GetLegalHoldAuthToken uid mpwd -> do - getLegalHoldAuthToken uid mpwd - AddLegalHoldClientToUserEither uid conn pks lpk -> do - addLegalHoldClientToUser uid conn pks lpk - RemoveLegalHoldClientFromUser uid -> do - removeLegalHoldClientFromUser uid - GetAccountConferenceCallingConfigClient uid -> do - getAccountConferenceCallingConfigClient uid - GetLocalMLSClients qusr ss -> do - getLocalMLSClients qusr ss - GetLocalMLSClient qusr cid ss -> do - getLocalMLSClient qusr cid ss - UpdateSearchVisibilityInbound status -> do - updateSearchVisibilityInbound status - DeleteBot convId botId -> - deleteBot convId botId - UpdateSearchIndex uid -> updateSearchIndex uid - GetAccountsBy localGetBy -> - getAccountsBy localGetBy - GetUsersByVariousKeys uids handles emails includePendingInvitations -> - getUsersByVariousKeys uids handles emails includePendingInvitations - CreateGroupInternal managedBy teamId creatorUserId newGroup -> - createGroupInternal managedBy teamId creatorUserId newGroup - GetGroupsInternal tid mbFilter mbManagedBy startIndex mbCount -> - getGroupsInternal tid mbFilter mbManagedBy startIndex mbCount - GetGroupInternal tid gid includeChannels -> - getGroupInternal tid gid includeChannels - UpdateGroup req -> - updateGroup req - DeleteGroupInternal managedBy teamId groupId -> - deleteGroupInternal managedBy teamId groupId - GetAppIdsForTeam teamId -> - getAppIdsForTeam teamId - SetAccountStatus uid status -> - setAccountStatus uid status - DeleteApp teamId uid -> - deleteApp teamId uid - CreateSAML uref buid teamid name managedBy handle richInfo mLocale role -> - createSAML uref buid teamid name managedBy handle richInfo mLocale role - CreateNoSAML extId email uid teamid uname locale role -> - createNoSAML extId email uid teamid uname locale role - UpdateEmail uid email activation -> - updateEmail uid email activation - GetAccount havePending uid -> - getAccount havePending uid - GetAccountByHandle handle -> - getByHandle handle - GetByEmail email -> - getByEmail email - SetName uid name -> - setName uid name - SetHandle uid handle -> - setHandle uid handle - SetManagedBy uid managedBy -> - setManagedBy uid managedBy - DeletePendingEmailUpdate uid -> - deletePendingEmailUpdate uid - SetSSOId uid ssoId -> - setSSOId uid ssoId - SetRichInfo uid richInfo -> - setRichInfo uid richInfo - SetLocale uid mLocale -> - setLocale uid mLocale - GetRichInfo uid -> - getRichInfo uid - CheckHandleAvailable handle -> - checkHandleAvailable handle - SsoLogin uid mLabel -> - ssoLogin uid mLabel - GetStatus uid -> - getStatus uid - GetStatusMaybe uid -> - getStatusMaybe uid - SetStatus uid status -> - setStatus uid status - GetDefaultUserLocale -> - getDefaultUserLocale - CheckAdminGetTeamId uid -> - checkAdminGetTeamId uid - SendSAMLIdPChangedEmail notif -> - sendSAMLIdPChangedEmail notif + interpret $ + runInputConst brigEndpoint . \case + GetConnectionsUnqualified uids muids mrel -> do + getConnectionsUnqualified uids muids mrel + GetConnections uids mquids mrel -> do + getConnections uids mquids mrel + PutConnectionInternal uc -> do + putConnectionInternal uc + ReauthUser uid reauth -> do + reAuthUser uid reauth + LookupActivatedUsers uids -> do + lookupActivatedUsers uids + GetUsers uids -> do + getUsers uids + DeleteUser uid -> do + deleteUser uid + GetContactList uid -> do + getContactList uid + GetUserExportData uid -> do + getUserExportData uid + GetSize tid -> do + getSize tid + LookupClients uids -> do + lookupClients uids + LookupClientsFull uids -> do + lookupClientsFull uids + NotifyClientsAboutLegalHoldRequest self other pk -> do + notifyClientsAboutLegalHoldRequest self other pk + GetLegalHoldAuthToken uid mpwd -> do + getLegalHoldAuthToken uid mpwd + AddLegalHoldClientToUserEither uid conn pks lpk -> do + addLegalHoldClientToUser uid conn pks lpk + RemoveLegalHoldClientFromUser uid -> do + removeLegalHoldClientFromUser uid + GetAccountConferenceCallingConfigClient uid -> do + getAccountConferenceCallingConfigClient uid + GetLocalMLSClients qusr ss -> do + getLocalMLSClients qusr ss + GetLocalMLSClient qusr cid ss -> do + getLocalMLSClient qusr cid ss + UpdateSearchVisibilityInbound status -> do + updateSearchVisibilityInbound status + DeleteBot convId botId -> + deleteBot convId botId + UpdateSearchIndex uid -> updateSearchIndex uid + GetAccountsBy localGetBy -> + getAccountsBy localGetBy + GetUsersByVariousKeys uids handles emails includePendingInvitations -> + getUsersByVariousKeys uids handles emails includePendingInvitations + CreateGroupInternal managedBy teamId creatorUserId newGroup -> + createGroupInternal managedBy teamId creatorUserId newGroup + GetGroupsInternal tid mbFilter mbManagedBy startIndex mbCount -> + getGroupsInternal tid mbFilter mbManagedBy startIndex mbCount + GetGroupInternal tid gid includeChannels -> + getGroupInternal tid gid includeChannels + UpdateGroup req -> + updateGroup req + DeleteGroupInternal managedBy teamId groupId -> + deleteGroupInternal managedBy teamId groupId + GetAppIdsForTeam teamId -> + getAppIdsForTeam teamId + SetAccountStatus uid status -> + setAccountStatus uid status + DeleteApp teamId uid -> + deleteApp teamId uid + CreateSAML uref buid teamid name managedBy handle richInfo mLocale role -> + createSAML uref buid teamid name managedBy handle richInfo mLocale role + CreateNoSAML extId email uid teamid uname locale role -> + createNoSAML extId email uid teamid uname locale role + UpdateEmail uid email activation -> + updateEmail uid email activation + GetAccount havePending uid -> + getAccount havePending uid + GetAccountByHandle handle -> + getByHandle handle + GetByEmail email -> + getByEmail email + SetName uid name -> + setName uid name + SetHandle uid handle -> + setHandle uid handle + SetManagedBy uid managedBy -> + setManagedBy uid managedBy + DeletePendingEmailUpdate uid -> + deletePendingEmailUpdate uid + SetSSOId uid ssoId -> + setSSOId uid ssoId + SetRichInfo uid richInfo -> + setRichInfo uid richInfo + SetLocale uid mLocale -> + setLocale uid mLocale + GetRichInfo uid -> + getRichInfo uid + CheckHandleAvailable handle -> + checkHandleAvailable handle + SsoLogin uid mLabel -> + ssoLogin uid mLabel + GetStatus uid -> + getStatus uid + GetStatusMaybe uid -> + getStatusMaybe uid + SetStatus uid status -> + setStatus uid status + GetDefaultUserLocale -> + getDefaultUserLocale + CheckAdminGetTeamId uid -> + checkAdminGetTeamId uid + SendSAMLIdPChangedEmail notif -> + sendSAMLIdPChangedEmail notif brigRequest :: (Member Rpc r, Member (Input Endpoint) r) => (Request -> Request) -> Sem r (Response (Maybe LByteString)) brigRequest req = do diff --git a/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs b/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs index e285ee4ad20..6317ed7ba2d 100644 --- a/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs +++ b/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs @@ -29,7 +29,6 @@ import Data.Conduit.List qualified as CL import Data.Id import Data.Json.Util (UTCTimeMillis (fromUTCTimeMillis)) import Data.Map qualified as Map -import Data.Set qualified as Set import Database.Bloodhound qualified as ES import Imports import Polysemy @@ -38,7 +37,6 @@ import Polysemy.TinyLog import Polysemy.TinyLog qualified as Log import System.Logger.Message qualified as Log import UnliftIO (pooledForConcurrentlyN) -import Wire.API.Team.Collaborator (gTeam, gUser) import Wire.API.Team.Feature import Wire.API.Team.Member.Info import Wire.API.Team.Role @@ -47,7 +45,6 @@ import Wire.IndexedUserStore (IndexedUserStore) import Wire.IndexedUserStore qualified as IndexedUserStore import Wire.IndexedUserStore.MigrationStore import Wire.IndexedUserStore.MigrationStore qualified as MigrationStore -import Wire.TeamCollaboratorsStore (TeamCollaboratorsStore, getTeamCollaborationsForUsers) import Wire.UserSearch.Migration import Wire.UserSearch.Types import Wire.UserStore @@ -57,15 +54,15 @@ type IOInterpreter r = forall a. Sem r a -> IO a -- | Increase this number any time you want to force reindexing. expectedMigrationVersion :: MigrationVersion -expectedMigrationVersion = MigrationVersion 7 +expectedMigrationVersion = MigrationVersion 6 -syncAllUsers :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore r) => IOInterpreter r -> Int32 -> IO () +syncAllUsers :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r) => IOInterpreter r -> Int32 -> IO () syncAllUsers interpreter pageSize = syncAllUsersWithVersion interpreter pageSize ES.ExternalGT -forceSyncAllUsers :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore r) => IOInterpreter r -> Int32 -> IO () +forceSyncAllUsers :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r) => IOInterpreter r -> Int32 -> IO () forceSyncAllUsers interpreter pageSize = syncAllUsersWithVersion interpreter pageSize ES.ExternalGTE -syncAllUsersWithVersion :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore r) => IOInterpreter r -> Int32 -> (ES.ExternalDocVersion -> ES.VersionControl) -> IO () +syncAllUsersWithVersion :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r) => IOInterpreter r -> Int32 -> (ES.ExternalDocVersion -> ES.VersionControl) -> IO () syncAllUsersWithVersion interpreter pageSize mkVersion = runConduit $ zipSources (CL.sourceList [1 ..]) (paginateWithStateC (interpreter . getIndexUsersPaginated pageSize)) @@ -117,12 +114,6 @@ syncAllUsersWithVersion interpreter pageSize mkVersion = fmap Map.unions . pooledForConcurrentlyN 16 (Map.toList teams) $ \(t, us) -> getRoles t (fmap (.userId) us) - -- One query for the whole page. A failure here fails every document of the - -- page, which 'logAndHush' then logs and skips. - eithCollabTeams :: Either SomeException (Map UserId [TeamId]) <- - try . fmap (Map.fromListWith (<>) . map (\tc -> (gUser tc, [gTeam tc]))) . interpreter $ - getTeamCollaborationsForUsers (Set.fromList (map (.userId) page)) - let vis :: IndexUser -> Either SomeException SearchVisibilityInbound vis indexUser = fromMaybe (Right defaultSearchVisibilityInbound) $ flip Map.lookup visMap =<< indexUser.teamId @@ -131,8 +122,7 @@ syncAllUsersWithVersion interpreter pageSize mkVersion = mkUserDoc indexUser = do currentVis <- vis indexUser currentRole <- sequence $ Map.lookup indexUser.userId roles - currentCollabTeams <- Map.findWithDefault [] indexUser.userId <$> eithCollabTeams - pure $ indexUserToDoc currentVis ((.value) <$> currentRole) currentCollabTeams indexUser + pure $ indexUserToDoc currentVis ((.value) <$> currentRole) indexUser mkDocVersion :: IndexUser -> Either SomeException ES.VersionControl mkDocVersion u = do @@ -169,7 +159,7 @@ syncAllUsersWithVersion interpreter pageSize mkVersion = <$> permissionsToRole tmi.permissions migrateData :: - (Member (Embed IO) r, Member IndexedUserStore r, Member (Error MigrationException) r, Member IndexedUserMigrationStore r, Member TinyLog r, Member UserStore r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore r) => + (Member (Embed IO) r, Member IndexedUserStore r, Member (Error MigrationException) r, Member IndexedUserMigrationStore r, Member TinyLog r, Member UserStore r, Member GalleyAPIAccess r) => IOInterpreter r -> Int32 -> IO () diff --git a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs index 07572a29851..156f8f6e479 100644 --- a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs +++ b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs @@ -526,17 +526,13 @@ matchSelf :: UserId -> Maybe ES.Query matchSelf searcher = Just (termQ "_id" (idToText searcher)) -- | Exclude apps from other teams. --- Apps should only be searchable within their own team, or within a team they --- collaborate with. +-- Apps should only be searchable within their own team. matchAppsFromOtherTeams :: Maybe TeamId -> Maybe ES.Query matchAppsFromOtherTeams mSearcherTeamId = Just $ ES.QueryBoolQuery boolQuery - { -- Apps collaborating with the searcher's team are not excluded. - ES.boolQueryMustNotMatch = - maybeToList (termQ "collaborating_teams" . idToText <$> mSearcherTeamId), - ES.boolQueryMustMatch = + { ES.boolQueryMustMatch = [ -- Match apps (type = "app") termQ "type" "app", -- That are from a different team than the searcher @@ -644,16 +640,7 @@ restrictSearchSpaceByUserType = \case else ES.TermsQuery "type" (userTypeFilterToText <$> (utsH :| utsT)) matchTeamMembersOf :: TeamId -> ES.Query -matchTeamMembersOf team = - ES.QueryBoolQuery - boolQuery - { ES.boolQueryShouldMatch = - [ -- Match users who are members of the team - ES.TermQuery (ES.Term "team" $ idToText team) Nothing, - -- Match users who are collaborators in the team - ES.TermQuery (ES.Term "collaborating_teams" $ idToText team) Nothing - ] - } +matchTeamMembersOf team = ES.TermQuery (ES.Term "team" $ idToText team) Nothing matchTeamMembersSearchableByAllTeams :: ES.Query matchTeamMembersSearchableByAllTeams = diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs index ebf79c96721..fcdf0731b08 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs @@ -29,8 +29,6 @@ data TeamCollaboratorsStore m a where GetAllTeamCollaborators :: TeamId -> TeamCollaboratorsStore m [TeamCollaborator] GetTeamCollaborator :: TeamId -> UserId -> TeamCollaboratorsStore m (Maybe TeamCollaborator) GetTeamCollaborations :: UserId -> TeamCollaboratorsStore m ([TeamCollaborator]) - -- | Batched 'GetTeamCollaborations', for callers that process users in pages. - GetTeamCollaborationsForUsers :: Set UserId -> TeamCollaboratorsStore m [TeamCollaborator] GetTeamCollaboratorsWithIds :: Set TeamId -> Set UserId -> TeamCollaboratorsStore m [TeamCollaborator] UpdateTeamCollaborator :: UserId -> TeamId -> Set CollaboratorPermission -> TeamCollaboratorsStore m () RemoveTeamCollaborator :: UserId -> TeamId -> TeamCollaboratorsStore m () diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs index b898ae69b51..a6a1e968a72 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs @@ -49,7 +49,6 @@ interpretTeamCollaboratorsStoreToPostgres = GetAllTeamCollaborators teamId -> getAllTeamCollaboratorsImpl teamId GetTeamCollaborator teamId userId -> getTeamCollaboratorImpl teamId userId GetTeamCollaborations userId -> getTeamCollaborationsImpl userId - GetTeamCollaborationsForUsers userIds -> getTeamCollaborationsForUsersImpl userIds GetTeamCollaboratorsWithIds teamIds userIds -> getTeamCollaboratorsWithIdsImpl teamIds userIds UpdateTeamCollaborator userId teamId permissions -> updateTeamCollaboratorImpl userId teamId permissions RemoveTeamCollaborator userId teamId -> removeTeamCollaboratorImpl userId teamId @@ -182,22 +181,6 @@ getTeamCollaborationsImpl teamId = do select user_id :: uuid, team_id :: uuid, permissions :: int2[] from collaborators where user_id = ($1 :: uuid) |] -getTeamCollaborationsForUsersImpl :: - (PGConstraints r) => - Set UserId -> - Sem r [TeamCollaborator] -getTeamCollaborationsForUsersImpl userIds = do - runStatement (Data.Set.toList userIds) getAllCollaborationsByUsersStatement - where - getAllCollaborationsByUsersStatement :: Statement [UserId] [TeamCollaborator] - getAllCollaborationsByUsersStatement = - dimap - (Data.Vector.fromList . Imports.map toUUID) - (Data.Vector.toList . (toTeamCollaborator <$>)) - $ [vectorStatement| - select user_id :: uuid, team_id :: uuid, permissions :: int2[] from collaborators where user_id = ANY($1 :: uuid[]) - |] - getTeamCollaboratorsWithIdsImpl :: (PGConstraints r) => Set TeamId -> diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs index bb0541636b0..30a970706e3 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs @@ -29,8 +29,6 @@ import Wire.API.Error.Brig qualified as E import Wire.API.Event.Team import Wire.API.Team.Collaborator import Wire.API.Team.Member qualified as TeamMember -import Wire.BrigAPIAccess (BrigAPIAccess) -import Wire.BrigAPIAccess qualified as BrigAPIAccess import Wire.Error import Wire.NotificationSubsystem import Wire.Sem.Now @@ -46,18 +44,15 @@ interpretTeamCollaboratorsSubsystem :: Member Now r, Member NotificationSubsystem r ) => - InterpreterFor BrigAPIAccess r -> InterpreterFor TeamCollaboratorsSubsystem r -interpretTeamCollaboratorsSubsystem brigAPIAccess = - interpret $ - brigAPIAccess . \case - CreateTeamCollaborator zUser user team perms -> createTeamCollaboratorImpl zUser user team perms - GetAllTeamCollaborators zUser team -> getAllTeamCollaboratorsImpl zUser team - InternalGetTeamCollaborator team user -> internalGetTeamCollaboratorImpl team user - InternalGetTeamCollaborations userId -> internalGetTeamCollaborationsImpl userId - InternalGetTeamCollaboratorsWithIds teams userIds -> internalGetTeamCollaboratorsWithIdsImpl teams userIds - InternalUpdateTeamCollaborator user team perms -> internalUpdateTeamCollaboratorImpl user team perms - InternalRemoveTeamCollaborator user team -> internalRemoveTeamCollaboratorImpl user team +interpretTeamCollaboratorsSubsystem = interpret $ \case + CreateTeamCollaborator zUser user team perms -> createTeamCollaboratorImpl zUser user team perms + GetAllTeamCollaborators zUser team -> getAllTeamCollaboratorsImpl zUser team + InternalGetTeamCollaborator team user -> internalGetTeamCollaboratorImpl team user + InternalGetTeamCollaborations userId -> internalGetTeamCollaborationsImpl userId + InternalGetTeamCollaboratorsWithIds teams userIds -> internalGetTeamCollaboratorsWithIdsImpl teams userIds + InternalUpdateTeamCollaborator user team perms -> internalUpdateTeamCollaboratorImpl user team perms + InternalRemoveTeamCollaborator user team -> internalRemoveTeamCollaboratorImpl user team internalGetTeamCollaboratorImpl :: (Member Store.TeamCollaboratorsStore r) => @@ -79,8 +74,7 @@ createTeamCollaboratorImpl :: Member (Error TeamCollaboratorsError) r, Member Store.TeamCollaboratorsStore r, Member Now r, - Member NotificationSubsystem r, - Member BrigAPIAccess r + Member NotificationSubsystem r ) => Local UserId -> UserId -> @@ -91,11 +85,9 @@ createTeamCollaboratorImpl zUser user team perms = do guardPermission (tUnqualified zUser) team TeamMember.GetTeamCollaborators InsufficientRights Store.createTeamCollaborator user team perms + -- TODO: Review the event's values generateTeamEvents (tUnqualified zUser) team [EdCollaboratorAdd user (Set.toList perms)] - -- Reindex the collaborator with their new collaboration team - BrigAPIAccess.updateSearchIndex user - getAllTeamCollaboratorsImpl :: ( Member TeamSubsystem r, Member (Error TeamCollaboratorsError) r, @@ -117,25 +109,21 @@ internalGetTeamCollaboratorsWithIdsImpl = do Store.getTeamCollaboratorsWithIds internalUpdateTeamCollaboratorImpl :: - (Member Store.TeamCollaboratorsStore r, Member BrigAPIAccess r) => + (Member Store.TeamCollaboratorsStore r) => UserId -> TeamId -> Set CollaboratorPermission -> Sem r () internalUpdateTeamCollaboratorImpl user team perms = do Store.updateTeamCollaborator user team perms - -- Reindex collaborator when permissions change - BrigAPIAccess.updateSearchIndex user internalRemoveTeamCollaboratorImpl :: - (Member Store.TeamCollaboratorsStore r, Member BrigAPIAccess r) => + (Member Store.TeamCollaboratorsStore r) => UserId -> TeamId -> Sem r () internalRemoveTeamCollaboratorImpl user team = do Store.removeTeamCollaborator user team - -- Reindex collaborator when removed - BrigAPIAccess.updateSearchIndex user -- This is of general usefulness. However, we cannot move this to wire-api as -- this would lead to a cyclic dependency. diff --git a/libs/wire-subsystems/src/Wire/UserSearch/Types.hs b/libs/wire-subsystems/src/Wire/UserSearch/Types.hs index 5464dae2a8f..5e8dcac765e 100644 --- a/libs/wire-subsystems/src/Wire/UserSearch/Types.hs +++ b/libs/wire-subsystems/src/Wire/UserSearch/Types.hs @@ -79,10 +79,7 @@ data UserDoc = UserDoc udScimExternalId :: Maybe Text, udSso :: Maybe Sso, udEmailUnvalidated :: Maybe EmailAddress, - udSearchable :: Maybe Bool, - -- | Teams that have added this user as a collaborator. - -- Updated separately via 'syncUserIndexCollaborations' when collaborator relationships change. - udCollaboratingTeams :: [TeamId] + udSearchable :: Maybe Bool } deriving (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform UserDoc) @@ -107,8 +104,7 @@ instance ToJSON UserDoc where "scim_external_id" .= udScimExternalId ud, "sso" .= udSso ud, "email_unvalidated" .= udEmailUnvalidated ud, - "searchable" .= udSearchable ud, - "collaborating_teams" .= udCollaboratingTeams ud + "searchable" .= udSearchable ud ] instance FromJSON UserDoc where @@ -132,7 +128,6 @@ instance FromJSON UserDoc where <*> o .:? "sso" <*> o .:? "email_unvalidated" <*> o .:? "searchable" - <*> o .:? "collaborating_teams" .!= [] searchVisibilityInboundFieldName :: Key searchVisibilityInboundFieldName = "search_visibility_inbound" diff --git a/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs b/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs index b051132f1ed..09ac630d191 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs @@ -126,8 +126,8 @@ indexUserToVersion :: Maybe (WithWritetime Role) -> IndexUser -> IndexVersion indexUserToVersion role iu = mkIndexVersion [Just $ Writetime iu.updatedAt, const () <$$> fmap writetime role] -indexUserToDoc :: SearchVisibilityInbound -> Maybe Role -> [TeamId] -> IndexUser -> UserDoc -indexUserToDoc searchVisInbound mRole collaboratingTeams IndexUser {..} = +indexUserToDoc :: SearchVisibilityInbound -> Maybe Role -> IndexUser -> UserDoc +indexUserToDoc searchVisInbound mRole IndexUser {..} = if shouldIndex then UserDoc @@ -148,8 +148,7 @@ indexUserToDoc searchVisInbound mRole collaboratingTeams IndexUser {..} = udHandle = handle, udNormalized = Just $ normalized name.fromName, udName = Just name, - udTeam = teamId, - udCollaboratingTeams = collaboratingTeams + udTeam = teamId } else -- We insert a tombstone-style user here, as it's easier than -- deleting the old one. It's mostly empty, but having the status here @@ -210,6 +209,5 @@ emptyUserDoc uid = udNormalized = Nothing, udName = Nothing, udTeam = Nothing, - udCollaboratingTeams = [], udId = uid } diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index f78029cebde..d5cb2dfee62 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -60,7 +60,6 @@ import Wire.API.Federation.Error import Wire.API.MLS.CipherSuite (CipherSuiteTag, csSignatureScheme) import Wire.API.Routes.FederationDomainConfig import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti (TeamStatus (..)) -import Wire.API.Team.Collaborator (gTeam) import Wire.API.Team.Export import Wire.API.Team.Feature import Wire.API.Team.Member @@ -103,8 +102,6 @@ import Wire.Sem.Metrics qualified as Metrics import Wire.Sem.Now (Now) import Wire.Sem.Now qualified as Now import Wire.StoredUser -import Wire.TeamCollaboratorsStore (TeamCollaboratorsStore) -import Wire.TeamCollaboratorsStore qualified as TeamCollaboratorsStore import Wire.TeamSubsystem import Wire.UserGroupStore (UserGroupStore, getUserGroupIdsForUsers) import Wire.UserKeyStore @@ -144,7 +141,6 @@ runUserSubsystem :: Member TinyLog r, Member (Input UserSubsystemConfig) r, Member TeamSubsystem r, - Member TeamCollaboratorsStore r, Member UserGroupStore r, Member (Input (Local any)) r ) => @@ -715,7 +711,6 @@ updateUserProfileImpl :: Member Events r, Member GalleyAPIAccess r, Member IndexedUserStore r, - Member TeamCollaboratorsStore r, Member Metrics r ) => Local UserId -> @@ -777,7 +772,6 @@ updateHandleImpl :: Member Events r, Member UserStore r, Member IndexedUserStore r, - Member TeamCollaboratorsStore r, Member Metrics r ) => Local UserId -> @@ -845,8 +839,7 @@ syncUserIndex :: ( Member UserStore r, Member GalleyAPIAccess r, Member IndexedUserStore r, - Member Metrics r, - Member TeamCollaboratorsStore r + Member Metrics r ) => UserId -> Sem r () @@ -867,13 +860,9 @@ syncUserIndex uid = teamSearchVisibilityInbound indexUser.teamId tm <- maybe (pure Nothing) selectTeamMember indexUser.teamId - collabTeams <- map gTeam <$> TeamCollaboratorsStore.getTeamCollaborations uid let mRole = tm >>= mkRoleWithWriteTime - userDoc = indexUserToDoc vis (value <$> mRole) collabTeams indexUser - -- GTE, not GT: the version comes from the user row alone, but the document also - -- holds data that changes without touching that row (collaborations), and under - -- GT those updates would be dropped as version conflicts. Older writes still lose. - version = ES.ExternalGTE . ES.ExternalDocVersion . docVersion $ indexUserToVersion mRole indexUser + userDoc = indexUserToDoc vis (value <$> mRole) indexUser + version = ES.ExternalGT . ES.ExternalDocVersion . docVersion $ indexUserToVersion mRole indexUser Metrics.incCounter indexUpdateCounter IndexedUserStore.upsert (userIdToDocId uid) userDoc version @@ -1191,7 +1180,6 @@ acceptTeamInvitationImpl :: Member (Error UserSubsystemError) r, Member InvitationStore r, Member IndexedUserStore r, - Member TeamCollaboratorsStore r, Member Metrics r, Member Events r, Member AuthenticationSubsystem r, @@ -1256,7 +1244,6 @@ removeEmailEitherImpl :: Member UserStore r, Member Events r, Member IndexedUserStore r, - Member TeamCollaboratorsStore r, Member (Input UserSubsystemConfig) r, Member GalleyAPIAccess r, Member Metrics r @@ -1293,7 +1280,6 @@ setUserSearchableImpl :: Member TeamSubsystem r, Member GalleyAPIAccess r, Member IndexedUserStore r, - Member TeamCollaboratorsStore r, Member Metrics r ) => Local UserId -> diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index ff27a27fa7f..1324d919db3 100644 --- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs +++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs @@ -365,21 +365,22 @@ miniBackendLowerEffectsInterpreters mb@(MiniBackendParams {..}) = . runInputConst conversationCfg . runClientSubsystem undefined undefined where + -- Mock BrigAPIAccess interpreter for tests + mockBrigAPIAccess :: forall r'. InterpreterFor BrigAPIAccess r' + mockBrigAPIAccess = interpret $ \case + _ -> error "Unimplemented BrigAPIAccess operation in mock" -- Mock UserClientIndexStore interpreter for tests mockUserClientIndexStore :: forall r'. InterpreterFor UserClientIndexStore r' mockUserClientIndexStore = interpret $ \case _ -> error "Unimplemented UserClientIndexStore operation in mock" - -- Mock BackendNotificationQueueAccess interpreter for tests mockBackendNotificationQueueAccess :: forall r'. InterpreterFor BackendNotificationQueueAccess r' mockBackendNotificationQueueAccess = interpret $ \case _ -> error "Unimplemented BackendNotificationQueueAccess operation in mock" - -- Mock ConversationSubsystem interpreter for tests mockConversationSubsystem :: forall r'. InterpreterFor ConversationSubsystem r' mockConversationSubsystem = interpretH $ \case _ -> error "Unimplemented ConversationSubsystem operation in mock" - mockMlsKeyPackageSubsystem :: forall r'. InterpreterFor MlsKeyPackageSubsystem r' mockMlsKeyPackageSubsystem = interpret $ \case HasMlsKeyPackages {} -> pure False @@ -785,7 +786,7 @@ interpretMaybeFederationStackState :: Sem (MiniBackendEffects `Append` r) a -> Sem r (MiniBackend, a) interpretMaybeFederationStackState mb = - miniBackendLowerEffectsInterpreters mb . interpretTeamCollaboratorsSubsystem subsume . runRecursiveAuthUserApp + miniBackendLowerEffectsInterpreters mb . interpretTeamCollaboratorsSubsystem . runRecursiveAuthUserApp -- FUTUREWORK(fisx): it would be nice to have a definition of an -- interpreter of all the subsystems combined, but since the diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs index ea57140aad5..4630c0c7f77 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs @@ -25,7 +25,6 @@ import Wire.MockInterpreters.AppStore as MockInterpreters import Wire.MockInterpreters.AuthenticationSubsystem as MockInterpreters import Wire.MockInterpreters.BackgroundJobPublisher as MockInterpreters import Wire.MockInterpreters.BlockListStore as MockInterpreters -import Wire.MockInterpreters.BrigAPIAccess as MockInterpreters import Wire.MockInterpreters.ClientStore as MockInterpreters import Wire.MockInterpreters.ConversationStore as MockInterpreters import Wire.MockInterpreters.ConversationSubsystem as MockInterpreters diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs deleted file mode 100644 index 5aacaddac97..00000000000 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs +++ /dev/null @@ -1,80 +0,0 @@ --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2025 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Wire.MockInterpreters.BrigAPIAccess where - -import Imports -import Polysemy -import Wire.BrigAPIAccess - --- | Errors out on everything except 'UpdateSearchIndex', which is a no-op. -mockBrigAPIAccess :: InterpreterFor BrigAPIAccess r -mockBrigAPIAccess = interpret $ \case - GetConnectionsUnqualified {} -> error "GetConnectionsUnqualified: implement on demand (mockBrigAPIAccess)" - GetConnections {} -> error "GetConnections: implement on demand (mockBrigAPIAccess)" - PutConnectionInternal {} -> error "PutConnectionInternal: implement on demand (mockBrigAPIAccess)" - ReauthUser {} -> error "ReauthUser: implement on demand (mockBrigAPIAccess)" - LookupActivatedUsers {} -> error "LookupActivatedUsers: implement on demand (mockBrigAPIAccess)" - GetUsers {} -> error "GetUsers: implement on demand (mockBrigAPIAccess)" - DeleteUser {} -> error "DeleteUser: implement on demand (mockBrigAPIAccess)" - GetContactList {} -> error "GetContactList: implement on demand (mockBrigAPIAccess)" - GetSize {} -> error "GetSize: implement on demand (mockBrigAPIAccess)" - LookupClients {} -> error "LookupClients: implement on demand (mockBrigAPIAccess)" - LookupClientsFull {} -> error "LookupClientsFull: implement on demand (mockBrigAPIAccess)" - NotifyClientsAboutLegalHoldRequest {} -> error "NotifyClientsAboutLegalHoldRequest: implement on demand (mockBrigAPIAccess)" - GetLegalHoldAuthToken {} -> error "GetLegalHoldAuthToken: implement on demand (mockBrigAPIAccess)" - AddLegalHoldClientToUserEither {} -> error "AddLegalHoldClientToUserEither: implement on demand (mockBrigAPIAccess)" - RemoveLegalHoldClientFromUser {} -> error "RemoveLegalHoldClientFromUser: implement on demand (mockBrigAPIAccess)" - GetAccountConferenceCallingConfigClient {} -> error "GetAccountConferenceCallingConfigClient: implement on demand (mockBrigAPIAccess)" - GetLocalMLSClients {} -> error "GetLocalMLSClients: implement on demand (mockBrigAPIAccess)" - GetLocalMLSClient {} -> error "GetLocalMLSClient: implement on demand (mockBrigAPIAccess)" - UpdateSearchVisibilityInbound {} -> error "UpdateSearchVisibilityInbound: implement on demand (mockBrigAPIAccess)" - GetUserExportData {} -> error "GetUserExportData: implement on demand (mockBrigAPIAccess)" - DeleteBot {} -> error "DeleteBot: implement on demand (mockBrigAPIAccess)" - UpdateSearchIndex _ -> pure () - GetAccountsBy {} -> error "GetAccountsBy: implement on demand (mockBrigAPIAccess)" - GetUsersByVariousKeys {} -> error "GetUsersByVariousKeys: implement on demand (mockBrigAPIAccess)" - CreateGroupInternal {} -> error "CreateGroupInternal: implement on demand (mockBrigAPIAccess)" - GetGroupInternal {} -> error "GetGroupInternal: implement on demand (mockBrigAPIAccess)" - GetGroupsInternal {} -> error "GetGroupsInternal: implement on demand (mockBrigAPIAccess)" - UpdateGroup {} -> error "UpdateGroup: implement on demand (mockBrigAPIAccess)" - DeleteGroupInternal {} -> error "DeleteGroupInternal: implement on demand (mockBrigAPIAccess)" - DeleteApp {} -> error "DeleteApp: implement on demand (mockBrigAPIAccess)" - GetAppIdsForTeam {} -> error "GetAppIdsForTeam: implement on demand (mockBrigAPIAccess)" - SetAccountStatus {} -> error "SetAccountStatus: implement on demand (mockBrigAPIAccess)" - CreateSAML {} -> error "CreateSAML: implement on demand (mockBrigAPIAccess)" - CreateNoSAML {} -> error "CreateNoSAML: implement on demand (mockBrigAPIAccess)" - UpdateEmail {} -> error "UpdateEmail: implement on demand (mockBrigAPIAccess)" - GetAccount {} -> error "GetAccount: implement on demand (mockBrigAPIAccess)" - GetAccountByHandle {} -> error "GetAccountByHandle: implement on demand (mockBrigAPIAccess)" - GetByEmail {} -> error "GetByEmail: implement on demand (mockBrigAPIAccess)" - SetName {} -> error "SetName: implement on demand (mockBrigAPIAccess)" - SetHandle {} -> error "SetHandle: implement on demand (mockBrigAPIAccess)" - SetManagedBy {} -> error "SetManagedBy: implement on demand (mockBrigAPIAccess)" - DeletePendingEmailUpdate {} -> error "DeletePendingEmailUpdate: implement on demand (mockBrigAPIAccess)" - SetSSOId {} -> error "SetSSOId: implement on demand (mockBrigAPIAccess)" - SetRichInfo {} -> error "SetRichInfo: implement on demand (mockBrigAPIAccess)" - SetLocale {} -> error "SetLocale: implement on demand (mockBrigAPIAccess)" - GetRichInfo {} -> error "GetRichInfo: implement on demand (mockBrigAPIAccess)" - CheckHandleAvailable {} -> error "CheckHandleAvailable: implement on demand (mockBrigAPIAccess)" - SsoLogin {} -> error "SsoLogin: implement on demand (mockBrigAPIAccess)" - GetStatus {} -> error "GetStatus: implement on demand (mockBrigAPIAccess)" - GetStatusMaybe {} -> error "GetStatusMaybe: implement on demand (mockBrigAPIAccess)" - SetStatus {} -> error "SetStatus: implement on demand (mockBrigAPIAccess)" - GetDefaultUserLocale {} -> error "GetDefaultUserLocale: implement on demand (mockBrigAPIAccess)" - CheckAdminGetTeamId {} -> error "CheckAdminGetTeamId: implement on demand (mockBrigAPIAccess)" - SendSAMLIdPChangedEmail {} -> error "SendSAMLIdPChangedEmail: implement on demand (mockBrigAPIAccess)" diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs index 63a334527ab..4def51eeef7 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs @@ -41,8 +41,6 @@ inMemoryTeamCollaboratorsStoreInterpreter = gets $ \(s :: Map TeamId [TeamCollaborator]) -> find (\tc -> tc.gUser == userId) =<< Map.lookup teamId s GetTeamCollaborations userId -> gets $ \(s :: Map TeamId [TeamCollaborator]) -> concatMap (filter (\tc -> tc.gUser == userId)) (Map.elems s) - GetTeamCollaborationsForUsers userIds -> - gets $ \(s :: Map TeamId [TeamCollaborator]) -> concatMap (filter (\tc -> tc.gUser `elem` userIds)) (Map.elems s) GetTeamCollaboratorsWithIds teamIds userIds -> gets $ \(s :: Map TeamId [TeamCollaborator]) -> concatMap (concatMap (filter (\tc -> tc.gUser `elem` userIds)) . (\(tid :: TeamId) -> Map.lookup tid s)) teamIds diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs index 043f3a834db..786f733240f 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs @@ -87,7 +87,7 @@ inMemoryUserSubsystemInterpreter = BlockListInsert _ -> error "BlockListInsert: implement on demand (userSubsystemInterpreter)" UpdateTeamSearchVisibilityInbound _ -> error "UpdateTeamSearchVisibilityInbound: implement on demand (userSubsystemInterpreter)" AcceptTeamInvitation {} -> error "AcceptTeamInvitation: implement on demand (userSubsystemInterpreter)" - InternalUpdateSearchIndex {} -> error "InternalUpdateSearchIndex: implement on demand (userSubsystemInterpreter)" + InternalUpdateSearchIndex _ -> error "InternalUpdateSearchIndex: implement on demand (userSubsystemInterpreter)" InternalFindTeamInvitation {} -> error "InternalFindTeamInvitation: implement on demand (userSubsystemInterpreter)" GetUserExportData _ -> error "GetUserExportData: implement on demand (userSubsystemInterpreter)" RemoveEmailEither _ -> error "RemoveEmailEither: implement on demand (userSubsystemInterpreter)" diff --git a/libs/wire-subsystems/test/unit/Wire/ScimSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ScimSubsystem/InterpreterSpec.hs index f623e0012dd..6861f097797 100644 --- a/libs/wire-subsystems/test/unit/Wire/ScimSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ScimSubsystem/InterpreterSpec.hs @@ -42,7 +42,7 @@ import Wire.API.User as User import Wire.API.User.Scim import Wire.API.UserGroup import Wire.BrigAPIAccess (BrigAPIAccess (..)) -import Wire.MockInterpreters hiding (mockBrigAPIAccess) +import Wire.MockInterpreters import Wire.ScimSubsystem import Wire.ScimSubsystem.Interpreter import Wire.StoredUser diff --git a/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs index 0a18b9d62fb..a09d56bd8ff 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs @@ -50,7 +50,6 @@ userDoc1 = UserDoc { udId = fromJust . hush . parseIdFromText $ "0a96b396-57d6-11ea-a04b-7b93d1a5c19c", udTeam = hush . parseIdFromText $ "17c59b18-57d6-11ea-9220-8bbf5eee961a", - udCollaboratingTeams = either (error . show) (: []) . parseIdFromText $ "17c59b18-57d6-11ea-9220-8bbf5eee961a", udName = Just . Name $ "Carl Phoomp", udNormalized = Just $ "carl phoomp", udHandle = Just . fromJust . parseHandle $ "phoompy", @@ -71,4 +70,4 @@ userDoc1 = -- Dont touch this. This represents serialized legacy data. userDoc1ByteString :: LByteString -userDoc1ByteString = "{\"collaborating_teams\":[\"17c59b18-57d6-11ea-9220-8bbf5eee961a\"],\"email\":\"phoompy@example.com\",\"account_status\":\"active\",\"handle\":\"phoompy\",\"managed_by\":\"scim\",\"role\":\"admin\",\"accent_id\":32,\"name\":\"Carl Phoomp\",\"created_at\":\"2020-08-29T21:50:00.000Z\",\"team\":\"17c59b18-57d6-11ea-9220-8bbf5eee961a\",\"id\":\"0a96b396-57d6-11ea-a04b-7b93d1a5c19c\",\"normalized\":\"carl phoomp\",\"saml_idp\":\"https://issuer.net/214234\"}" +userDoc1ByteString = "{\"email\":\"phoompy@example.com\",\"account_status\":\"active\",\"handle\":\"phoompy\",\"managed_by\":\"scim\",\"role\":\"admin\",\"accent_id\":32,\"name\":\"Carl Phoomp\",\"created_at\":\"2020-08-29T21:50:00.000Z\",\"team\":\"17c59b18-57d6-11ea-9220-8bbf5eee961a\",\"id\":\"0a96b396-57d6-11ea-a04b-7b93d1a5c19c\",\"normalized\":\"carl phoomp\",\"saml_idp\":\"https://issuer.net/214234\"}" diff --git a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs index 9e309d50dda..68942a77cb3 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs @@ -1110,7 +1110,7 @@ spec = describe "UserSubsystem.Interpreter" do searchee = searcheeNoHandle {handle = Just searcheeHandle} :: StoredUser storedUserToDoc :: StoredUser -> UserDoc - storedUserToDoc user = indexUserToDoc defaultSearchVisibilityInbound Nothing [] (storedUserToIndexUser user) + storedUserToDoc user = indexUserToDoc defaultSearchVisibilityInbound Nothing (storedUserToIndexUser user) indexFromStoredUsers :: [StoredUser] -> UserIndex indexFromStoredUsers storedUsers = do diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 42196d6e18c..f7627036f1c 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -244,7 +244,6 @@ library Wire.BoundedQueue Wire.BoundedQueue.STM Wire.BrigAPIAccess - Wire.BrigAPIAccess.Local Wire.BrigAPIAccess.Rpc Wire.BudgetStore Wire.BudgetStore.Cassandra @@ -651,7 +650,6 @@ test-suite wire-subsystems-tests Wire.MockInterpreters.AuthenticationSubsystem Wire.MockInterpreters.BackgroundJobPublisher Wire.MockInterpreters.BlockListStore - Wire.MockInterpreters.BrigAPIAccess Wire.MockInterpreters.ClientStore Wire.MockInterpreters.ConversationStore Wire.MockInterpreters.ConversationSubsystem diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index 66dbee09c56..0d367f19eed 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -367,7 +367,7 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = ) . runFeaturesConfigSubsystem . runInputSem getAllTeamFeaturesForServer - . interpretTeamCollaboratorsSubsystem (interpretBrigAccess env.brigEndpoint) + . interpretTeamCollaboratorsSubsystem . discardMeetingNotifier . interpretConversationSubsystem where diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index 27812d06b47..6c2145ea8cd 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -36,7 +36,6 @@ module Brig.App cargoholdLens, galleyLens, galleyEndpointLens, - brigEndpointLens, sparEndpointLens, gundeckEndpointLens, cargoholdEndpointLens, @@ -190,10 +189,6 @@ data Env = Env { cargohold :: RPC.Request, galley :: RPC.Request, galleyEndpoint :: Endpoint, - -- | Brig's own listen address. Used only to call ourselves over HTTP for - -- 'BrigAPIAccess' operations that have no local implementation yet; see - -- 'Wire.BrigAPIAccess.Local'. - brigEndpoint :: Endpoint, sparEndpoint :: Endpoint, gundeckEndpoint :: Endpoint, cargoholdEndpoint :: Endpoint, @@ -312,7 +307,6 @@ newEnv opts = do { cargohold = mkEndpoint $ opts.cargohold, galley = mkEndpoint $ opts.galley, galleyEndpoint = opts.galley, - brigEndpoint = opts.brig, sparEndpoint = opts.spar, gundeckEndpoint = opts.gundeck, cargoholdEndpoint = opts.cargohold, diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index 4414567c910..f866fc5a9ca 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -47,7 +47,6 @@ import Polysemy.Input (Input, runInputConst) import Polysemy.Internal.Kind import Polysemy.Resource import Polysemy.TinyLog (TinyLog) -import Util.Options (Endpoint) import Wire.API.Error (ErrorS, errorToWai) import Wire.API.Error.Galley import Wire.API.Federation.Client qualified @@ -69,8 +68,6 @@ import Wire.BackgroundJobsPublisher (BackgroundJobPublisher) import Wire.BackgroundJobsPublisher.RabbitMQ (interpretBackgroundJobPublisherRabbitMQ) import Wire.BlockListStore import Wire.BlockListStore.Cassandra -import Wire.BrigAPIAccess (BrigAPIAccess) -import Wire.BrigAPIAccess.Local (interpretBrigAPIAccessLocally) import Wire.BudgetStore import Wire.BudgetStore.Cassandra import Wire.ClientStore (ClientStore) @@ -133,7 +130,6 @@ import Wire.PropertySubsystem.Interpreter import Wire.RateLimit import Wire.RateLimit.Interpreter import Wire.Rpc -import Wire.RpcException (RpcException) import Wire.SAMLEmailSubsystem import Wire.SAMLEmailSubsystem.Interpreter import Wire.SFT (SFT, interpretSFT) @@ -193,12 +189,13 @@ type RecursiveEffects = '[ AuthenticationSubsystem, UserSubsystem, AppSubsystem, - ClientSubsystem, - BrigAPIAccess, - TeamCollaboratorsSubsystem + ClientSubsystem ] -type NonRecursiveEffects2 = BrigLowerLevelEffects +type NonRecursiveEffects2 = + '[ TeamCollaboratorsSubsystem + ] + `Append` BrigLowerLevelEffects -- | These effects have interpreters which don't depend on each other type BrigLowerLevelEffects = @@ -279,7 +276,6 @@ type BrigLowerLevelEffects = Embed Cas.Client, Error ClientError, Error ParseException, - Error RpcException, Error ErrorCall, Error SomeException, Error HttpError, @@ -301,11 +297,9 @@ type BrigLowerLevelEffects = -- Cloned from "Wire.MiniBackend". runRecursiveEffects :: (Members NonRecursiveEffects2 r) => - -- | Brig's own endpoint; see 'interpretBrigAPIAccessLocally'. - Endpoint -> Sem (RecursiveEffects `Append` r) a -> Sem r a -runRecursiveEffects selfEndpoint = runTeamCollaborators . runBrigAPIAccess . runClient . runApp . runUser . runAuth +runRecursiveEffects = runClient . runApp . runUser . runAuth where runAuth :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor AuthenticationSubsystem r runAuth = interpretAuthenticationSubsystem runUser @@ -319,12 +313,6 @@ runRecursiveEffects selfEndpoint = runTeamCollaborators . runBrigAPIAccess . run runClient :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor ClientSubsystem r runClient = runClientSubsystem runAuth runUser - runBrigAPIAccess :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor BrigAPIAccess r - runBrigAPIAccess = interpretBrigAPIAccessLocally selfEndpoint runUser - - runTeamCollaborators :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor TeamCollaboratorsSubsystem r - runTeamCollaborators = interpretTeamCollaboratorsSubsystem runBrigAPIAccess - runBrigToIO :: App.Env -> AppT BrigCanonicalEffects a -> IO a runBrigToIO e (AppT ma) = do let blockedDomains = @@ -445,7 +433,6 @@ runBrigToIO e (AppT ma) = do . rethrowHttpErrorIO . runError @SomeException . mapError @ErrorCall SomeException - . mapError @RpcException SomeException . mapError @ParseException SomeException . mapError clientErrorToHttpError . interpretClientToIO e.casClient @@ -523,7 +510,8 @@ runBrigToIO e (AppT ma) = do . interpretTeamCollaboratorsStoreToPostgres . interpretTeamSubsystemToGalleyAPI . samlEmailSubsystemInterpreter - . runRecursiveEffects e.brigEndpoint + . interpretTeamCollaboratorsSubsystem + . runRecursiveEffects . interpretUserGroupSubsystem . maybe runEnterpriseLoginSubsystemNoConfig diff --git a/services/brig/src/Brig/Index/Eval.hs b/services/brig/src/Brig/Index/Eval.hs index d703437a3ad..ea72f9aeef5 100644 --- a/services/brig/src/Brig/Index/Eval.hs +++ b/services/brig/src/Brig/Index/Eval.hs @@ -51,7 +51,6 @@ import Polysemy.TinyLog (TinyLog) import System.Logger qualified as Log import System.Logger.Class (Logger) import Util.Options -import Wire.API.Team.Collaborator (TeamCollaboratorsError) import Wire.ClientSubsystem.Error (ClientError) import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.GalleyAPIAccess.Rpc @@ -66,8 +65,6 @@ import Wire.Rpc import Wire.Sem.Logger.TinyLog import Wire.Sem.Metrics (Metrics) import Wire.Sem.Metrics.IO -import Wire.TeamCollaboratorsStore (TeamCollaboratorsStore) -import Wire.TeamCollaboratorsStore.Postgres (interpretTeamCollaboratorsStoreToPostgres) import Wire.UserKeyStore (UserKeyStore) import Wire.UserKeyStore.Cassandra import Wire.UserSearch.Migration (MigrationException) @@ -78,7 +75,6 @@ import Wire.UserStore.Postgres (interpretUserStorePostgres) type BrigIndexEffectStack = [ UserKeyStore, UserStore, - TeamCollaboratorsStore, IndexedUserStore, Error IndexedUserStoreError, IndexedUserMigrationStore, @@ -90,7 +86,6 @@ type BrigIndexEffectStack = TinyLog, Input Hasql.Pool, Error UsageError, - Error TeamCollaboratorsError, Error ClientError, Embed IO, Final IO @@ -137,7 +132,6 @@ runSem (mgr, casClient, pgPool, bhEnv, indexedUserStoreConfig, reqId, migrationI runFinal . embedToFinal . throwErrorToIOFinal @ClientError - . throwErrorToIOFinal @TeamCollaboratorsError . throwPostgresUsageErrorToIOFinal . runInputConst pgPool . loggerToTinyLogReqId reqId logger @@ -149,7 +143,6 @@ runSem (mgr, casClient, pgPool, bhEnv, indexedUserStoreConfig, reqId, migrationI . interpretIndexedUserMigrationStoreES bhEnv migrationIndexName . throwErrorToIOFinal @IndexedUserStoreError . interpretIndexedUserStoreES indexedUserStoreConfig - . interpretTeamCollaboratorsStoreToPostgres . userStoreInterpreter . interpretUserKeyStoreCassandra casClient $ action diff --git a/services/brig/src/Brig/User/Search/Index.hs b/services/brig/src/Brig/User/Search/Index.hs index 68a17f07b1f..4c4919729d9 100644 --- a/services/brig/src/Brig/User/Search/Index.hs +++ b/services/brig/src/Brig/User/Search/Index.hs @@ -364,15 +364,6 @@ indexMapping = mpAnalyzer = Nothing, mpFields = mempty }, - -- teams this user collaborates with (without being a member of them) - "collaborating_teams" - .= MappingProperty - { mpType = MPKeyword, - mpStore = False, - mpIndex = True, - mpAnalyzer = Nothing, - mpFields = mempty - }, "accent_id" .= MappingProperty { mpType = MPByte, diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index f47dc7f3798..4d755d8c612 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -572,7 +572,7 @@ evalGalley e = . interpretTeamSubsystem teamSubsystemConfig . runFeaturesConfigSubsystem . runInputSem getAllTeamFeaturesForServer - . interpretTeamCollaboratorsSubsystem (interpretBrigAccess (e ^. brig)) + . interpretTeamCollaboratorsSubsystem . runFederationSubsystem conversationSubsystemConfig.federationProtocols . runInputConst (e ^. reqId) . interpretJobSubsystem From 358d6a8e1f4c263a3b65593a846d1fddbc741c75 Mon Sep 17 00:00:00 2001 From: Zebot Date: Thu, 27 Aug 2026 14:57:31 +0000 Subject: [PATCH 113/113] Add changelog for Release 2026-08-27 --- CHANGELOG.md | 459 ++++++++++++++++++ changelog.d/0-release-notes/WPB-25325 | 3 - changelog.d/0-release-notes/WPB-26489 | 7 - .../WPB-27912-background-effects | 9 - ...e-scim-error-responses-comply-with-rfc7644 | 22 - ...-role-field-in-user-schema-comply-with-rfc | 25 - .../WPB-28080-meeting-past-edit-period | 1 - ...8155-meeting-conversation-access-migration | 1 - .../disable-preventAdminlessGroups.md | 3 - .../0-release-notes/reaper-image-and-rbac | 12 - .../wpb-26771-meetings-premium.md | 9 - .../wpb-27175-meetings-email.md | 11 - .../wpb-27553-meeting-duration-tzid | 6 - .../WPB-23434-scim-emails-now-echo-type | 7 - changelog.d/1-api-changes/WPB-26626-events.md | 1 - changelog.d/1-api-changes/WPB-26704 | 1 - ...de-collaborator-apps-in-get-apps-end-point | 1 - .../WPB-27912-background-effects-endpoint | 7 - ...e-scim-error-responses-comply-with-rfc7644 | 1 - ...-used-in-user-_fixes-rfc-compliance-issue_ | 1 - ...-role-field-in-user-schema-comply-with-rfc | 1 - ...28028-mlsmigration-allowManualMigration.md | 5 - ...r-meetings-for-google-calendar-integration | 1 - .../WPB-28083-meeting-errors-exposed | 1 - changelog.d/1-api-changes/WPB-28272 | 2 - .../WPB-28299-finalize-api-version-17 | 1 - .../multi-ingress-mandatory-allowlist | 7 - .../wpb-26626-meeting-legacy-404 | 1 - .../wpb-26771-meetings-premium-endpoint.md | 8 - .../wpb-26773-meeting-start-not-past | 1 - .../wpb-27329-meetings-read-disabled | 1 - .../wpb-27373-meeting-drop-trial | 1 - .../wpb-27465-meeting-update-ongoing | 1 - .../wpb-27553-meeting-duration-tzid | 4 - changelog.d/2-features/WPB-23177 | 1 - changelog.d/2-features/WPB-26101 | 1 - changelog.d/2-features/WPB-26489 | 1 - changelog.d/2-features/WPB-26650 | 1 - changelog.d/2-features/WPB-27017 | 1 - .../WPB-27907-meeting-notifications-alignment | 1 - .../2-features/multi-ingress-cross-IdP-SSO | 10 - .../2-features/wpb-26705-meeting-events.md | 8 - .../wpb-27620-meeting-member-add.md | 5 - changelog.d/3-bug-fixes/WPB-21744 | 1 - changelog.d/3-bug-fixes/WPB-23177 | 1 - changelog.d/3-bug-fixes/WPB-23434 | 4 - changelog.d/3-bug-fixes/WPB-23434-email-type | 13 - .../3-bug-fixes/WPB-23434-multi-primary | 4 - changelog.d/3-bug-fixes/WPB-24669 | 1 - .../WPB-25521-finish-collaborator-crud-api | 1 - changelog.d/3-bug-fixes/WPB-25544 | 1 - ...m_-do-not-re-activate-any-apps-in-the-team | 1 - changelog.d/3-bug-fixes/WPB-27857 | 1 - ...rom-all-conversations_-instead-of-crashing | 1 - ...B-28132-fix-openapi3-docs-for-oauth-scopes | 1 - .../ephemeral-user-claim-key-package | 1 - .../federator-internal-status-cross-check | 1 - .../fix-sso-get-by-email-rate-limiting | 5 - .../multi-ingress-csp-host-scoping | 1 - .../3-bug-fixes/reject-duplicate-handles | 4 - .../scim-email-subattr-remove-null | 13 - .../3-bug-fixes/search-visibility-feature-key | 1 - ...ion-test-cleanup-on-federation-instance-v2 | 1 - .../WPB---update-postgres-schema-dump | 1 - ...eteness-tests-from-brig-to-wire-subsystems | 1 - .../5-internal/WPB-23434-scim-user-meta-store | 1 - changelog.d/5-internal/WPB-23631-0 | 1 - changelog.d/5-internal/WPB-23631-1 | 1 - changelog.d/5-internal/WPB-23631-10 | 1 - changelog.d/5-internal/WPB-23631-11 | 1 - changelog.d/5-internal/WPB-23631-12 | 1 - changelog.d/5-internal/WPB-23631-2 | 1 - changelog.d/5-internal/WPB-23631-3 | 1 - changelog.d/5-internal/WPB-23631-4 | 1 - changelog.d/5-internal/WPB-23631-5 | 1 - changelog.d/5-internal/WPB-23631-6 | 1 - changelog.d/5-internal/WPB-23631-7 | 2 - changelog.d/5-internal/WPB-23631-8 | 1 - changelog.d/5-internal/WPB-23631-9 | 1 - .../WPB-25475-multi-ingress-annotations | 4 - changelog.d/5-internal/WPB-26101 | 1 - changelog.d/5-internal/WPB-26823 | 10 - .../WPB-26823-recurrence-constraint | 4 - ...7162-wire-ingress-external-dns-annotations | 6 - .../WPB-27169-make-haddocks-more-readable | 1 - changelog.d/5-internal/WPB-27370-envoy-logs | 1 - .../WPB-28163-move-v17-endpoints-to-v18 | 1 - ...automate-license-header-updates-in-treefmt | 1 - ...annon-log-register-remote-presence-failure | 4 - ...-alpine-images-cannon-cassandra-migrations | 19 - .../5-internal/email-templates-v1.0.155 | 1 - changelog.d/5-internal/fix-sbomnix | 2 - .../5-internal/remove-bulk-get-rich-info | 1 - changelog.d/5-internal/remove-sftd-disco | 1 - .../wire-image-mirror-for-integration-tests | 5 - .../5-internal/wpb-27553-meeting-tzid-notnull | 3 - changelog.d/5-internal/zhost-domain | 4 - changelog.d/6-federation/WPB-27060 | 1 - 98 files changed, 459 insertions(+), 336 deletions(-) delete mode 100644 changelog.d/0-release-notes/WPB-25325 delete mode 100644 changelog.d/0-release-notes/WPB-26489 delete mode 100644 changelog.d/0-release-notes/WPB-27912-background-effects delete mode 100644 changelog.d/0-release-notes/WPB-27953-make-scim-error-responses-comply-with-rfc7644 delete mode 100644 changelog.d/0-release-notes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc delete mode 100644 changelog.d/0-release-notes/WPB-28080-meeting-past-edit-period delete mode 100644 changelog.d/0-release-notes/WPB-28155-meeting-conversation-access-migration delete mode 100644 changelog.d/0-release-notes/disable-preventAdminlessGroups.md delete mode 100644 changelog.d/0-release-notes/reaper-image-and-rbac delete mode 100644 changelog.d/0-release-notes/wpb-26771-meetings-premium.md delete mode 100644 changelog.d/0-release-notes/wpb-27175-meetings-email.md delete mode 100644 changelog.d/0-release-notes/wpb-27553-meeting-duration-tzid delete mode 100644 changelog.d/1-api-changes/WPB-23434-scim-emails-now-echo-type delete mode 100644 changelog.d/1-api-changes/WPB-26626-events.md delete mode 100644 changelog.d/1-api-changes/WPB-26704 delete mode 100644 changelog.d/1-api-changes/WPB-27705-roll-back_-do-_not_-include-collaborator-apps-in-get-apps-end-point delete mode 100644 changelog.d/1-api-changes/WPB-27912-background-effects-endpoint delete mode 100644 changelog.d/1-api-changes/WPB-27953-make-scim-error-responses-comply-with-rfc7644 delete mode 100644 changelog.d/1-api-changes/WPB-27953-scim_-advertise-all-schemas-used-in-user-_fixes-rfc-compliance-issue_ delete mode 100644 changelog.d/1-api-changes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc delete mode 100644 changelog.d/1-api-changes/WPB-28028-mlsmigration-allowManualMigration.md delete mode 100644 changelog.d/1-api-changes/WPB-28050-new-oauth-scopes-for-meetings-for-google-calendar-integration delete mode 100644 changelog.d/1-api-changes/WPB-28083-meeting-errors-exposed delete mode 100644 changelog.d/1-api-changes/WPB-28272 delete mode 100644 changelog.d/1-api-changes/WPB-28299-finalize-api-version-17 delete mode 100644 changelog.d/1-api-changes/multi-ingress-mandatory-allowlist delete mode 100644 changelog.d/1-api-changes/wpb-26626-meeting-legacy-404 delete mode 100644 changelog.d/1-api-changes/wpb-26771-meetings-premium-endpoint.md delete mode 100644 changelog.d/1-api-changes/wpb-26773-meeting-start-not-past delete mode 100644 changelog.d/1-api-changes/wpb-27329-meetings-read-disabled delete mode 100644 changelog.d/1-api-changes/wpb-27373-meeting-drop-trial delete mode 100644 changelog.d/1-api-changes/wpb-27465-meeting-update-ongoing delete mode 100644 changelog.d/1-api-changes/wpb-27553-meeting-duration-tzid delete mode 100644 changelog.d/2-features/WPB-23177 delete mode 100644 changelog.d/2-features/WPB-26101 delete mode 100644 changelog.d/2-features/WPB-26489 delete mode 100644 changelog.d/2-features/WPB-26650 delete mode 100644 changelog.d/2-features/WPB-27017 delete mode 100644 changelog.d/2-features/WPB-27907-meeting-notifications-alignment delete mode 100644 changelog.d/2-features/multi-ingress-cross-IdP-SSO delete mode 100644 changelog.d/2-features/wpb-26705-meeting-events.md delete mode 100644 changelog.d/2-features/wpb-27620-meeting-member-add.md delete mode 100644 changelog.d/3-bug-fixes/WPB-21744 delete mode 100644 changelog.d/3-bug-fixes/WPB-23177 delete mode 100644 changelog.d/3-bug-fixes/WPB-23434 delete mode 100644 changelog.d/3-bug-fixes/WPB-23434-email-type delete mode 100644 changelog.d/3-bug-fixes/WPB-23434-multi-primary delete mode 100644 changelog.d/3-bug-fixes/WPB-24669 delete mode 100644 changelog.d/3-bug-fixes/WPB-25521-finish-collaborator-crud-api delete mode 100644 changelog.d/3-bug-fixes/WPB-25544 delete mode 100644 changelog.d/3-bug-fixes/WPB-25579-if-apps-are-re-enabled-in-the-team_-do-not-re-activate-any-apps-in-the-team delete mode 100644 changelog.d/3-bug-fixes/WPB-27857 delete mode 100644 changelog.d/3-bug-fixes/WPB-28083-allow-team-admin-to-remove-bot-from-all-conversations_-instead-of-crashing delete mode 100644 changelog.d/3-bug-fixes/WPB-28132-fix-openapi3-docs-for-oauth-scopes delete mode 100644 changelog.d/3-bug-fixes/ephemeral-user-claim-key-package delete mode 100644 changelog.d/3-bug-fixes/federator-internal-status-cross-check delete mode 100644 changelog.d/3-bug-fixes/fix-sso-get-by-email-rate-limiting delete mode 100644 changelog.d/3-bug-fixes/multi-ingress-csp-host-scoping delete mode 100644 changelog.d/3-bug-fixes/reject-duplicate-handles delete mode 100644 changelog.d/3-bug-fixes/scim-email-subattr-remove-null delete mode 100644 changelog.d/3-bug-fixes/search-visibility-feature-key delete mode 100644 changelog.d/5-internal/WPB---fix-integration-test-cleanup-on-federation-instance-v2 delete mode 100644 changelog.d/5-internal/WPB---update-postgres-schema-dump delete mode 100644 changelog.d/5-internal/WPB-18127-move-email-template-completeness-tests-from-brig-to-wire-subsystems delete mode 100644 changelog.d/5-internal/WPB-23434-scim-user-meta-store delete mode 100644 changelog.d/5-internal/WPB-23631-0 delete mode 100644 changelog.d/5-internal/WPB-23631-1 delete mode 100644 changelog.d/5-internal/WPB-23631-10 delete mode 100644 changelog.d/5-internal/WPB-23631-11 delete mode 100644 changelog.d/5-internal/WPB-23631-12 delete mode 100644 changelog.d/5-internal/WPB-23631-2 delete mode 100644 changelog.d/5-internal/WPB-23631-3 delete mode 100644 changelog.d/5-internal/WPB-23631-4 delete mode 100644 changelog.d/5-internal/WPB-23631-5 delete mode 100644 changelog.d/5-internal/WPB-23631-6 delete mode 100644 changelog.d/5-internal/WPB-23631-7 delete mode 100644 changelog.d/5-internal/WPB-23631-8 delete mode 100644 changelog.d/5-internal/WPB-23631-9 delete mode 100644 changelog.d/5-internal/WPB-25475-multi-ingress-annotations delete mode 100644 changelog.d/5-internal/WPB-26101 delete mode 100644 changelog.d/5-internal/WPB-26823 delete mode 100644 changelog.d/5-internal/WPB-26823-recurrence-constraint delete mode 100644 changelog.d/5-internal/WPB-27162-wire-ingress-external-dns-annotations delete mode 100644 changelog.d/5-internal/WPB-27169-make-haddocks-more-readable delete mode 100644 changelog.d/5-internal/WPB-27370-envoy-logs delete mode 100644 changelog.d/5-internal/WPB-28163-move-v17-endpoints-to-v18 delete mode 100644 changelog.d/5-internal/WPB-28280-automate-license-header-updates-in-treefmt delete mode 100644 changelog.d/5-internal/cannon-log-register-remote-presence-failure delete mode 100644 changelog.d/5-internal/configurable-alpine-images-cannon-cassandra-migrations delete mode 100644 changelog.d/5-internal/email-templates-v1.0.155 delete mode 100644 changelog.d/5-internal/fix-sbomnix delete mode 100644 changelog.d/5-internal/remove-bulk-get-rich-info delete mode 100644 changelog.d/5-internal/remove-sftd-disco delete mode 100644 changelog.d/5-internal/wire-image-mirror-for-integration-tests delete mode 100644 changelog.d/5-internal/wpb-27553-meeting-tzid-notnull delete mode 100644 changelog.d/5-internal/zhost-domain delete mode 100644 changelog.d/6-federation/WPB-27060 diff --git a/CHANGELOG.md b/CHANGELOG.md index ea1e32e217d..093e56294e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,462 @@ +# [2026-08-27] (Chart Release 5.35.0) + +## Release notes + + +* The PostgreSQL connection pool implementation was switched to `hasql-resource-pool`. + The `agingTimeout` setting is now ignored and should be treated as deprecated. + Pool metrics now include acquisition/session latency. (#5323) + +* Background-worker now runs additional jobs and has new settings. The `jobs` settings configure the dispatcher, worker, retry, shutdown, and reaper behavior, with defaults matching the existing behavior. The initial queues are `meetings` and `conversations`, with one worker pool assigned to each queue. + + Operators should size the background-worker PostgreSQL pool and PostgreSQL `max_connections` for this workload and the transient connection used while acquiring the migration lock; that connection is closed after migrations complete. `jobs.workerThreads` defaults to `1`. + + Both worker pools use the same PostgreSQL pool. Connections are borrowed for active job transactions and short-lived worker operations, rather than being reserved permanently per pool. LISTEN/NOTIFY is disabled, so the job runner does not open an additional listener connection. + + The Helm chart sets `background-worker.terminationGracePeriodSeconds` to `40`, providing a margin over the default `jobs.gracefulShutdownTimeout` of `30s`. Adjust both settings together if changing the shutdown timeout. (#5289) + +* * The `backgroundEffects` team feature flag is **deprecated** (WPB-27912). Its + default is now **enabled and locked**, and the Helm configuration override for + `backgroundEffects` has been removed from `charts/wire-server`. The flag's + data type and its public/internal HTTP endpoints are retained for backward + compatibility; any Helm overrides for `backgroundEffects` are now ignored and + can be removed. The public/internal HTTP endpoints return 404 at API version + v17 and remain available through v16; the flag type remains deprecated. The + aggregate `GET /feature-configs` and `GET /teams/:tid/features` endpoints + continue to include `backgroundEffects` at all API versions, including v17. (#5431) + +* Make SCIM error responses comply with RFC7644. Any code that processes SCIM error responses must be changed to follow the standard, instead of the previous Wire implementation. + + Previous schema (incompatible with RFC): + + ``` + { + "code": 400, + "label": "scim-error", + "message": "{\"detail\":\"[...]\",\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:Error\"],\"scimType\":\"invalidValue\",\"status\":\"400\"}" + } + ``` + + New schema (RFC-compliant): + + ``` + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": "400" + "scimType": "invalidValue", + "detail": "[...]", + } + ``` (#5439) + +* SCIM: Make role and entitlements fields in user schema comply with RFC. Any code that processes SCIM users must be changed to follow the standard, instead of the previous Wire implementation. + + Previous User schema (incompatible with RFC): + + ``` + { + ... + "roles": ["member"], + "entitlements": ["some entitlement"], + ... + } + ``` + + New schema (RFC-compliant): + + ``` + { + ... + "roles": [{"value" : "member"}], + "entitlements": [{"value" : "some entitlement"}], + ... + } + ``` + + For backwards compatibility, both fields still accept the old bare-string form on input. (#5440) + +* Added a new galley setting `settings.meetings.pastEditPeriod` (default 24h): how far into the past `PUT /meetings/{domain}/{id}` may move a meeting's `start_time`/`end_time`, so past and ongoing meetings can be corrected after the fact. Previously any start time in the past (beyond a 60s tolerance) was rejected while a meeting was still upcoming. Galley refuses to start if `pastEditPeriod` is negative or greater than `settings.meetings.validityPeriod`. (#5451) + +* Update meeting conversations from `{private, invite}` to `{invite, code}` so meetings can be joined by code. (#5464) + +* The _prevent adminless groups_ feature has known bugs. Disable and lock it by + Helm configuration for now. Development of this feature will continue, it + should just not be used in production as-is. (#5472) + +* The `reaper` chart no longer grants itself `cluster-admin` and no longer uses an + unmaintained container image. Upgrading is a drop-in `helm upgrade`; no manual steps. + + Two cases need action: + + * If you override `image` in your values, update the override: the default changed from + `docker.io/bitnamilegacy/kubectl:1.32.4` to `docker.io/alpine/kubectl:1.36.3`. The + image must contain a POSIX shell at `/bin/sh` — distroless kubectl images do not work. + * If you mirror images into a private registry (airgapped installs), add the new image. + + See `charts/reaper/README.md` for the image settings, the RBAC the chart now creates, + and the rest of the changes. (#5444) + +* * The `meetingsPremium` team feature flag is **deprecated** (WPB-26771). It no + longer affects meeting behaviour: team meetings are always non-trial + regardless of its value. Its default is now **enabled and locked**, and the + Helm configuration override for `meetingsPremium` has been removed from + `charts/wire-server`. The flag's data type and its public/internal HTTP + endpoints are retained for backward compatibility but have no behavioural + effect; any Helm overrides for `meetingsPremium` are now ignored and can be + removed. The public/internal HTTP endpoints now return 404 at API version v17 + and remain available through v16; the flag type remains deprecated. The aggregate `GET /feature-configs` and `GET /teams/:tid/features` endpoints continue to include `meetingsPremium` at all API versions, including v17. (#5326) + +* * Galley has a new optional `settings.meetings.email` configuration block + (WPB-27175) for sending meeting-invitation emails to invited external + addresses. It takes a required `from` sender, an optional `replyTo` address, + and a `transport` that selects AWS SES or SMTP (the same shape Brig uses). + When the block is unset, meeting invitation emails are disabled. For SMTP, + set `galley.secrets.smtpPassword` (mounted at + `/etc/wire/galley/secrets/smtp-password.txt`) and point + `settings.meetings.email.smtp.passwordFile` at that path; the Galley ConfigMap + injects it into `transport.smtpCredentials.smtpPassword`, the same pattern + Brig uses for `smtp.passwordFile`. This change adds the + configuration plumbing only; email sending itself lands in a follow-up. (#5346) + +* Starting at API version V17, the `Meeting` type returned and accepted by the + meetings endpoints carries `tzid` (IANA time zone) and drops the deprecated + `trial` field; `end_time` is retained on both V17 and V16. The operator config + `galley.config.settings.meetings.legacyTimeZone` (default `Europe/Berlin`) now + applies only to meetings created by legacy clients (API < V17); reads no longer + need it, as `tzid` is persisted `NOT NULL` (backfilled to `Europe/Berlin`). (#5391) + + +## API changes + + +* SCIM user resources returned by spar now include `type` on stored email + addresses. Previously spar persisted no `type`, so SCIM PATCH operations with a + value-path filter like `emails[type eq "work"].value` (as sent by Microsoft + Entra ID) never matched the stored entry and silently appended a duplicate + email instead of updating the address in place. Clients that compare full SCIM + user payloads rather than individual fields (e.g. strict equality on the + `emails` array) will see the additional `type` member. (WPB-23434) (#5419) + +* Introduced meeting-specific conversation lifecycle events: `conversation.create-meeting` and `conversation.delete-meeting`. When a conversation of type meeting (`group_conv_type: "meeting"`) is created or deleted, clients receive these instead of `conversation.create` / `conversation.delete`. The payloads are identical to their non-meeting counterparts (`conversation.delete-meeting`, like `conversation.delete`, carries no `data`); only the event `type` differs, so clients can handle meetings distinctly. (WPB-26626) (#5421) + +* `POST /meetings` (create) and `PUT /meetings/{domain}/{id}` (update) now return a full `conversation` object alongside the existing meeting fields. The legacy `qualified_conversation` field is retained for backward compatibility. (#5301) + +* Roll back: do *not* include collaborator apps in get-apps end-point. (#5402) + +* The `backgroundEffects` team feature endpoints are deprecated and return 404 for + clients on API version v17: the public `GET`/`PUT /teams/:tid/features/backgroundEffects` + and the internal legacy lock `PUT /i/teams/:tid/features/backgroundEffects/(un)?locked`. + They remain available through v16. The aggregate endpoints + `GET /feature-configs` and `GET /teams/:tid/features` are unaffected and continue + to include `backgroundEffects` at all API versions: the aggregate feature list is + version-agnostic, like other version-gated features such as MLS. (#5431) + +* Make scim error responses comply with RFC7644. (#5439) + +* SCIM: advertise all schemas used in User (fixes RFC compliance issue). (#5441) + +* SCIM: Make role and entitlements fields in user schema comply with RFC. (#5440) + +* The `mlsMigration` team feature config now includes an `allowManualMigration` + boolean field (default `false`) that controls whether clients are permitted to + perform single-group (manual) MLS migrations. The field only steers client + behaviour (e.g. if a migration button is shown or not). It does not enforce + checks in the backend. (#5456) + +* New oauth scopes for meetings for calendar integration. (#5462) + +* Meeting endpoints (`POST /meetings`, `PUT/DELETE /meetings/:domain/:id`, meeting invitation endpoints): errors have dedicated descriptions. (#5455) + +* `PUT /meetings/{domain}/{id}` now accepts an optional `tzid` field to update a + meeting's IANA time zone. (#5479) + +* Finalize api version 17. (#5482) + +* To prevent security-relevant configuration mistakes, make configuration of + allowed IdP certificate fingerprints (`idpCertFingerprintAllowlist`) mandatory for + multi-ingress SSO. **This will break existing multi-ingress SSO flows until + `idpCertFingerprintAllowlist` is configured!** This breakage is unfortunately + necessary, because we're getting more lenient regarding the IdPs a user can use + to log in ("auto IdP migration"). Regular (non-multi-ingress) use cases are + unaffected. (#5327) + +* +`GET /conversations/{domain}/{id}` and the legacy `GET /conversations/{id}` now return 404 (`no-conversation`) when the conversation is a meeting, on API versions prior to V16, instead of returning the conversation with `group_conv_type: null`. The legacy batch endpoint `GET /conversations?ids=…` — itself removed at V3, so only ever available on V1–V2 — likewise omits meeting conversations from its results. Meeting conversations remain fully accessible from V16 onwards. (WPB-26626) (#5382) + +* The `meetingsPremium` team feature endpoints are deprecated and return 404 for + clients on API version v17: the public `GET`/`PUT /teams/:tid/features/meetingsPremium` + and the internal legacy lock `PUT /i/teams/:tid/features/meetingsPremium/(un)?locked`. + They remain available through v16. The flag has had no behavioural effect since + WPB-26771 (team meetings are always non-trial). The aggregate endpoints + `GET /feature-configs` and `GET /teams/:tid/features` are unaffected and continue + to include `meetingsPremium` at all API versions: the aggregate feature list is + version-agnostic, like other version-gated features such as MLS. (WPB-26771) (#5364) + +* Reject meeting creation and update when the start time is in the past (with a 60-second tolerance for clock skew). Previously, meetings could be created with arbitrary past start times. (#5325) + +* GET /meetings/list and GET /meetings/{domain}/{id} no longer return 403 invalid-op when the caller's team has the `meetings` feature disabled. The read endpoints now treat a disabled feature as "no meetings": GET /meetings/list returns 200 [], and GET /meetings/{domain}/{id} returns 404 meeting-not-found. Write operations (create, update, delete, invitation mutations) still return 403 invalid-op when the feature is disabled. Previously these read endpoints returned an undocumented 403 invalid-op for members of teams with the meetings feature disabled. (#5353) + +* The meetings endpoints (POST /meetings, PUT /meetings/{domain}/{id}, GET /meetings/{domain}/{id}, GET /meetings/list) drop the deprecated `trial` field from the `Meeting` response starting at API version V17. On V15–V16 the field is still present but always returns `false` (team meetings are never trial; see WPB-26771). The underlying storage is unchanged. (#5363) + +* PUT /meetings/{domain}/{id} can now edit a meeting that has already started (an ongoing meeting). The start-time-not-in-the-past validation added by WPB-26773 previously rejected any update whose start_time was in the past, which also blocked legitimate edits to ongoing meetings — whose start time is naturally in the past; the check now applies only to meetings that have not started yet. Creating a meeting with a past start time, and moving an upcoming meeting's start time into the past, remain rejected (WPB-27465). (#5373) + +* Starting at API version V17, the `Meeting` type carries a `tzid` (IANA time + zone) and drops the deprecated `trial` field; `end_time` is retained on both V17 + and V16. A V17 update that supplies only `start_time` leaves `end_time` + unchanged — pass `end_time` to reschedule the end. (#5391) + + +## Features + + +* Manual team invitations now conflict when a matching pending SCIM invitation already exists for the same team and email address. (#5400) + +* Add user contact-status enrichment to user listings based on available Proteus and MLS contact methods. (#5371) + +* Introduce schedulable background jobs, migrate meetings cleanup to the new job runner, and add the initial adminless reminder and deletion jobs. (#5289) + +* Skip senderless prevent-adminless deletion for federated conversations with remote members to prevent remote state drift. (#5425) + +* Add adminless-group reconciliation, teardown, and system events for member updates, reminders, and deletion. (#5357, #5390) + +* Filter Wire Meetings lifecycle events only on the originating client connection. (#5428) + +* When a team uses multiple SAML IdPs (one per ingress domain) in a multi-ingress + setup, users can now authenticate via any of the team's IdPs even if their + account was originally provisioned under a different one. Spar resolves the + correct account by email-based NameID lookup across all team IdPs and migrates + the user's SSO identity to the authenticating IdP transparently. + + **Important:** Email addresses (`NameID`s) must be unique across configured + IdPs! Otherwise, users may be logged into wrong accounts! + + Please refer to the documentation for further information. (#5212) + +* * Added meeting lifecycle events: `meeting.create`, `meeting.update`, and + `meeting.delete` (WPB-26705). These websocket notifications are pushed to all + local members of the meeting's conversation on every successful create, update, + and delete operation. Each payload carries the event `type`, the meeting's + qualified ID in the top-level `qualified_id` field, the + `qualified_conversation`, `qualified_from`, `via`, `time`, and optional `team`. + Meeting events use a dedicated event envelope (not the conversation event + envelope). (#5330) + +* Added `meeting.member-add` websocket event (WPB-27620). When a user becomes a + member of an MLS meeting conversation, a `meeting.member-add` lifecycle event is + pushed to the newly-added local members, alongside the existing `meeting.create`, + `meeting.update`, and `meeting.delete` events. The payload uses the same meeting + event structure as the other meeting lifecycle events. (#5383) + + +## Bug fixes and other updates + + +* When a user is put under SCIM control, any pending email-address update is now invalidated (the unvalidated email and its activation token are removed). Previously, team settings kept offering a "resend verification" action that could not succeed (failing with `403 managed-by-scim`), and a stale activation link could still change a SCIM-managed user's email outside of SCIM. (#5333) + +* Release a handle claimed after a SCIM invitation expired, before cleanup, preventing a subsequent team invitation from using that handle. (#5400) + +* SCIM PATCH now supports the `emails` multi-valued attribute (e.g. Entra's + `emails[type eq "work"].value`), so user emails can be updated via SCIM. Identity + providers that previously hit a `can not lens into multi-valued attributes yet` + error when provisioning emails now succeed. (#5419) + +* SCIM email metadata is now persisted and echoed verbatim. spar stores the + `type` and `primary` sub-attributes of the SCIM email entry it keeps (in + `spar.scim_user_times`) and echoes them back on GET/POST/PATCH exactly as the + IdP sent them, instead of synthesizing a hardcoded `type` of `"work"` (email + `type` values are limited to 64 characters). As a result, PATCH value-path + filters like Entra's `emails[type eq "work"].value` and Okta's + `emails[primary eq true].value` match the stored entry for an in-place update + when (and only when) the IdP actually supplied that metadata at provisioning + time; users provisioned without it echo neither field. Since per RFC 7644 + §3.5.2 an `Add` on a non-existing target creates it, a type-filter PATCH + against a user whose stored email carries no such metadata appends a new + entry (which the single-email reduction collapses back to the old address, + i.e. no visible change), while a primary-filter PATCH is a complete no-op. (#5419) + +* SCIM user provisioning now rejects requests (HTTP 400) that mark more than one + email as `primary`, an RFC 7643 §2.4 violation. Previously spar silently picked + one primary and dropped the rest, masking client-side misconfiguration. Requests + with zero or one primary email are unchanged. (#5419) + +* Fixed asset uploads with non-ASCII filenames when audit logging is enabled. Audit-log metadata is now percent-encoded before being stored in S3 metadata headers and decoded when read back. (#5359) + +* Fix nginz routes for delete / update collaborator. Move collaborator CRUD api to galley. (#5334) + +* Users marked non-searchable are no longer returned across federation. (#5282) + +* If apps are re-enabled in the team, DO NOT re-activate any apps in the team. (#5347) + +* Wire Meetings lifecycle events (meeting.create, meeting.update, meeting.delete) are no longer delivered to the user who triggered the action, consistent with how other event types avoid echoing back to the originator. Previously the creator/updater/deleter received their own meeting event. (#5426) + +* Allow team admin to remove bot from all conversations, instead of crashing. (This is how it's already done in 'finishDeleteService'.) (#5450) + +* Fix openapi3 docs for oauth scopes. (#5457) + +* Enable claiming key packages for ephemeral users (#5339) + +* The federator internal listener's /i/status health check now correctly verifies the external listener is ready instead of checking itself, so readiness no longer reports up before the external federation listener is bound. (#5403) + +* Reorder SSO nginx locations to enforce correct rate limiting: + `/sso/get-by-email` needs to appear before `/sso` in nginx's config, because + regex locations are matched in order (first-match), not by specificity. In + previous order `/sso` caught before `/sso/get-by-email` applied the specific + 5r/m rate limit, leaving it on the generic 50r/s limit. (#5341) + +* Fixed Content Security Policy header scoping in multi-ingress Kubernetes configuration. CSP headers set via the Ingress nginx configuration-snippet are now properly scoped to exclude the webapp domain, preventing conflicts with the webapp's own CSP headers that are set independently. (#5432) + +* SCIM: Avoid assigning an already claimed handle to a user. + + Before this if SCIM created a user with a handle already claimed by another user + the handle would get stored for the user even if the overall SCIM call fails. (#5475) + +* SCIM PATCH on `emails` now handles the email `type` and `primary` + sub-attributes per RFC 7644 §3.5.2.2 and RFC 7643 §2.5: + `remove emails[type eq "work"].type` (or `.primary`) unassigns just that + sub-attribute and keeps the entry (including the address and the other + sub-attribute) instead of deleting the whole record; `replace`/`add` with an + explicit `"value": null` unassigns the sub-attribute the same way; and a + filterless `remove emails` clears all email entries. Removing or nulling the + `.value` sub-attribute is rejected with a 400 pointing at whole-entry removal, + since the address is the record's identity. As a consequence of preserving + explicit `null` values in PATCH operations, `replace` with `null` on + `displayName`/`externalId`/`active` now unassigns the attribute (RFC 7643 §2.5) + like the corresponding `remove` already did, instead of failing with + "No value was provided". (#5419) + +* Fixed a discrepancy between the canonical `searchVisibility` feature key used in runtime JSON and the legacy `teamSearchVisibility` key used in YAML configuration. Both names are now accepted; no configuration changes or other operator actions are required. (#5338) + + +## Internal changes + + +* Fix integration test cleanup on federation instance V2. (#5447) + +* Update postgres schema dump. (#5461) + +* Move email template completeness tests from brig to wire-subsystems. (#5429) + +* Rename `Wire.ScimUserTimesStore` to `Wire.ScimUserMetaStore` (`ScimUserTimes` -> `ScimUserMeta`); the store also holds SCIM email metadata now. The Cassandra table `spar.scim_user_times` is unchanged. (#5419) + +* Move `Spar.Sem.Reporter` to `Wire.Reporter` (#5377) + +* Move `Spar.Sem.SamlProtocolSettings` to `Wire.SamlProtocolSettings` (#5377) + +* Move `Brig.Effects.JwtTools` in `Wire.JwtTools`. (#5396) + +* Move `Brig.Budget` in `Wire.BudgetStore`. (#5397) + +* Move `Galley.Effects.Queue` in `Wire.BoundedQueue`. (#5398) + +* Move `Spar.Sem.ScimUserTimesStore` to `Wire.ScimUserTimesStore`. (#5378) + +* Move `Spar.Sem.DefaultSsoCode` in `Wire.DefaultSsoStore` (#5379) + +* Move `Spar.Sem.IdPRawMetadataStore` to `Wire.IdPRawMetadataStore`. (#5380) + +* Move `Spar.Sem.VerdictFormatStore` in `Wire.VerdictFormatStore`. (#5388) + +* Move `Spar.Sem.ScimExternalIdStore` in `Wire.ScimExternalIdStore`. (#5392, #5423) + +* Read the DPoP public key bundle once at startup and cache it, instead of + re-reading the file on every request; the `PublicKeyBundle` effect is removed. (#5393) + +* Move `Brig.Effects.UserPendingActivationStore` in `Wire.UserPendingActivationStore`. (#5394) + +* Move `Brig.Effects.SFT` in `Wire.SFT`. (#5395) + +* In multi-domain (multi-ingress) mode, the `wire-ingress` chart now applies the + `httpRoute.annotations` passthrough to every per-domain HTTPRoute, so external-dns + weighted-record annotations (set-identifier/aws-weight) work across all backend + domains. The `service.create` toggle also applies in multi-domain deployments. (#5307) + +* Extract MLS key-package handling into a subsystem and split Cassandra access into a dedicated store. (#5368) + +* Added partial indexes and rewrote the meetings cleanup query so the background + worker stays fully index-backed as the `meetings` table grows: + - `idx_meetings_recurrence_eff_end` on `GREATEST(end_time, recurrence_until)` + for bounded recurring meetings (covers the recurring branches of the list and + cleanup queries). + - `idx_meetings_end_time_nonrecurring` on `end_time` for non-recurring meetings, + so cleanup can find expired non-recurring meetings without scanning + not-yet-expired recurring rows whose original slot is long past. + `getOldMeetingsImpl` now issues one bounded, index-backed query per meeting kind + and merges the two batches. (#5328) + +* Added a `meetings_recurrence_consistency` CHECK constraint so the recurrence + columns can never be left in a partially-set state (frequency is the master + switch; interval is required when set; recurrence_until stays optional for + open-ended recurring meetings). (#5328) + +* The wire-ingress and nginx-ingress-services charts now expose annotation + passthroughs (`httpRoute.annotations` on the HTTPRoutes, `ingress.annotations` on + the Ingresses), so external-dns weighted records (set-identifier/aws-weight) can + be attached to both routers for a zero-downtime nginx-to-Envoy DNS cutover. + wire-ingress also gains a `service.create` toggle (default true) to reuse the + backend Services owned by nginx-ingress-services while both run in parallel. (#5358) + +* Make haddocks more readable. (#5446) + +* When using the wire-ingress provided EnvoyProxy, ensure the request path's query string is removed from the logs, to ensure access_tokens passed as query string are not logged. Browsers are required to pass them on a query string on some endpoints such as /await. (#5361) + +* Moved the following endpoints from development version V17 to the new development version V18: `PUT /conversations/:domain/:cnv/members` (rejection of replacements that would leave a group adminless), `PUT /teams/:tid/features/preventAdminlessGroups` (duration-string request body), and `POST /register` (403 for SCIM-managed users changing their name). V17 behaves like V16 for these endpoints. API version V18 was created as a development version; V17 remains a development version until finalized. The changelog entry for the moved members endpoint is parked in `changelog.d/99-pending/`, which `mk-changelog.sh` and `mk-cleanup.sh` now skip. (#5468) + +* Automate license header updates in treefmt. (#5481) + +* Cannon now logs an error when registering a client's remote presence with + Gundeck fails, so operators can tell this apart from an actual + websocket/network issue (e.g. `PongTimeout` caused by Gundeck losing its Redis + connection). (#5454) + +* The alpine base images used by the `cannon-configurator` initContainer (`wire-server` + chart) and the `job-done` container (`cassandra-migrations` chart) are no longer + hard-coded. They can now be set via `cannon.configuratorImage.{repository,tag,pullPolicy}` + in the `wire-server` chart and `jobDoneImage.{repository,tag}` in the + `cassandra-migrations` chart. + + The default was bumped from `alpine:3.21.3` to `alpine:3.24.1`, since alpine 3.21 + reaches end-of-support on 2026-11-01. The local integration stack's `init_vhosts` + container also moved from `alpine/curl:3.14` (an Alpine 3.14 image last published + in 2021) to `alpine/curl:8.21.0`. + + Operators who mirror images into a private registry should make sure the new + `alpine:3.24.1` tag is cached, or override `repository` to point at their mirror. + + The chart release tooling (`hack/bin/set-wire-server-image-version.sh`, + `hack/bin/set-chart-image-version.sh`) now anchors its version stamping to + `repository: quay.io/wire/` lines instead of matching `tag:` by indentation, so + third-party image tags in `values.yaml` are no longer overwritten with the + wire-server release version. (#5408) + +* Updated email templates to v1.0.155 (#5344) + +* Fix `#sbom` Nix env / sbomnix usage by upgrading to latest stable version of + the latter. The issue was introduced by upgrading `nixpkgs` to 26.05. + +* brig: Remove /i/users/rich-info (#5384) + +* remove sftd_disco (now lives in wireapp/wire-avs-service) (#5056) + +* Use wire image mirror for integration tests as `public.ecr.aws/bitnami/` is no + longer available + (https://aws.amazon.com/blogs/containers/bitnami-image-removal-from-ecr-public/). + Docker Hub has strict rate-limiting. So, in lieu of better options, we now use + our own image cache at `quay.io/wire/mirror-images`. (#5360) + +* Internal: `meetings.tzid` is now `NOT NULL`, backfilled to `Europe/Berlin`. + `end_time` is the source of truth (there are no `duration`/`duration_original` + columns). (WPB-27553) (#5391) + +* The `Z-Host` header has been treated as domain, but used as `Text`. + De-serializing and thus using it as `Domain` increases type-safety and ensures + domain related semantics; e.g. case insensitivity in equality checks. + This solves a `FUTUREWORK` remark which was around for quite some time. (#5320) + + +## Federation changes + + +* `deeplink.json` now contains a new optional field `supportEmail` that may be used by clients. (#5351) + + # [2026-07-07] (Chart Release 5.34.0) ## Release notes diff --git a/changelog.d/0-release-notes/WPB-25325 b/changelog.d/0-release-notes/WPB-25325 deleted file mode 100644 index e9526beeb71..00000000000 --- a/changelog.d/0-release-notes/WPB-25325 +++ /dev/null @@ -1,3 +0,0 @@ -The PostgreSQL connection pool implementation was switched to `hasql-resource-pool`. -The `agingTimeout` setting is now ignored and should be treated as deprecated. -Pool metrics now include acquisition/session latency. diff --git a/changelog.d/0-release-notes/WPB-26489 b/changelog.d/0-release-notes/WPB-26489 deleted file mode 100644 index b800b5205e9..00000000000 --- a/changelog.d/0-release-notes/WPB-26489 +++ /dev/null @@ -1,7 +0,0 @@ -Background-worker now runs additional jobs and has new settings. The `jobs` settings configure the dispatcher, worker, retry, shutdown, and reaper behavior, with defaults matching the existing behavior. The initial queues are `meetings` and `conversations`, with one worker pool assigned to each queue. - -Operators should size the background-worker PostgreSQL pool and PostgreSQL `max_connections` for this workload and the transient connection used while acquiring the migration lock; that connection is closed after migrations complete. `jobs.workerThreads` defaults to `1`. - -Both worker pools use the same PostgreSQL pool. Connections are borrowed for active job transactions and short-lived worker operations, rather than being reserved permanently per pool. LISTEN/NOTIFY is disabled, so the job runner does not open an additional listener connection. - -The Helm chart sets `background-worker.terminationGracePeriodSeconds` to `40`, providing a margin over the default `jobs.gracefulShutdownTimeout` of `30s`. Adjust both settings together if changing the shutdown timeout. diff --git a/changelog.d/0-release-notes/WPB-27912-background-effects b/changelog.d/0-release-notes/WPB-27912-background-effects deleted file mode 100644 index 379491a3272..00000000000 --- a/changelog.d/0-release-notes/WPB-27912-background-effects +++ /dev/null @@ -1,9 +0,0 @@ -* The `backgroundEffects` team feature flag is **deprecated** (WPB-27912). Its - default is now **enabled and locked**, and the Helm configuration override for - `backgroundEffects` has been removed from `charts/wire-server`. The flag's - data type and its public/internal HTTP endpoints are retained for backward - compatibility; any Helm overrides for `backgroundEffects` are now ignored and - can be removed. The public/internal HTTP endpoints return 404 at API version - v17 and remain available through v16; the flag type remains deprecated. The - aggregate `GET /feature-configs` and `GET /teams/:tid/features` endpoints - continue to include `backgroundEffects` at all API versions, including v17. diff --git a/changelog.d/0-release-notes/WPB-27953-make-scim-error-responses-comply-with-rfc7644 b/changelog.d/0-release-notes/WPB-27953-make-scim-error-responses-comply-with-rfc7644 deleted file mode 100644 index d769a62ddfb..00000000000 --- a/changelog.d/0-release-notes/WPB-27953-make-scim-error-responses-comply-with-rfc7644 +++ /dev/null @@ -1,22 +0,0 @@ -Make SCIM error responses comply with RFC7644. Any code that processes SCIM error responses must be changed to follow the standard, instead of the previous Wire implementation. - -Previous schema (incompatible with RFC): - -``` -{ - "code": 400, - "label": "scim-error", - "message": "{\"detail\":\"[...]\",\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:Error\"],\"scimType\":\"invalidValue\",\"status\":\"400\"}" -} -``` - -New schema (RFC-compliant): - -``` -{ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status": "400" - "scimType": "invalidValue", - "detail": "[...]", -} -``` diff --git a/changelog.d/0-release-notes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc b/changelog.d/0-release-notes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc deleted file mode 100644 index bfd6336569b..00000000000 --- a/changelog.d/0-release-notes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc +++ /dev/null @@ -1,25 +0,0 @@ -SCIM: Make role and entitlements fields in user schema comply with RFC. Any code that processes SCIM users must be changed to follow the standard, instead of the previous Wire implementation. - -Previous User schema (incompatible with RFC): - -``` -{ - ... - "roles": ["member"], - "entitlements": ["some entitlement"], - ... -} -``` - -New schema (RFC-compliant): - -``` -{ - ... - "roles": [{"value" : "member"}], - "entitlements": [{"value" : "some entitlement"}], - ... -} -``` - -For backwards compatibility, both fields still accept the old bare-string form on input. diff --git a/changelog.d/0-release-notes/WPB-28080-meeting-past-edit-period b/changelog.d/0-release-notes/WPB-28080-meeting-past-edit-period deleted file mode 100644 index 44a229af4ef..00000000000 --- a/changelog.d/0-release-notes/WPB-28080-meeting-past-edit-period +++ /dev/null @@ -1 +0,0 @@ -Added a new galley setting `settings.meetings.pastEditPeriod` (default 24h): how far into the past `PUT /meetings/{domain}/{id}` may move a meeting's `start_time`/`end_time`, so past and ongoing meetings can be corrected after the fact. Previously any start time in the past (beyond a 60s tolerance) was rejected while a meeting was still upcoming. Galley refuses to start if `pastEditPeriod` is negative or greater than `settings.meetings.validityPeriod`. diff --git a/changelog.d/0-release-notes/WPB-28155-meeting-conversation-access-migration b/changelog.d/0-release-notes/WPB-28155-meeting-conversation-access-migration deleted file mode 100644 index d3dd31d463d..00000000000 --- a/changelog.d/0-release-notes/WPB-28155-meeting-conversation-access-migration +++ /dev/null @@ -1 +0,0 @@ -Update meeting conversations from `{private, invite}` to `{invite, code}` so meetings can be joined by code. diff --git a/changelog.d/0-release-notes/disable-preventAdminlessGroups.md b/changelog.d/0-release-notes/disable-preventAdminlessGroups.md deleted file mode 100644 index 14eed273faf..00000000000 --- a/changelog.d/0-release-notes/disable-preventAdminlessGroups.md +++ /dev/null @@ -1,3 +0,0 @@ -The _prevent adminless groups_ feature has known bugs. Disable and lock it by -Helm configuration for now. Development of this feature will continue, it -should just not be used in production as-is. diff --git a/changelog.d/0-release-notes/reaper-image-and-rbac b/changelog.d/0-release-notes/reaper-image-and-rbac deleted file mode 100644 index a974b94ca38..00000000000 --- a/changelog.d/0-release-notes/reaper-image-and-rbac +++ /dev/null @@ -1,12 +0,0 @@ -The `reaper` chart no longer grants itself `cluster-admin` and no longer uses an -unmaintained container image. Upgrading is a drop-in `helm upgrade`; no manual steps. - -Two cases need action: - -* If you override `image` in your values, update the override: the default changed from - `docker.io/bitnamilegacy/kubectl:1.32.4` to `docker.io/alpine/kubectl:1.36.3`. The - image must contain a POSIX shell at `/bin/sh` — distroless kubectl images do not work. -* If you mirror images into a private registry (airgapped installs), add the new image. - -See `charts/reaper/README.md` for the image settings, the RBAC the chart now creates, -and the rest of the changes. diff --git a/changelog.d/0-release-notes/wpb-26771-meetings-premium.md b/changelog.d/0-release-notes/wpb-26771-meetings-premium.md deleted file mode 100644 index 003c60b205a..00000000000 --- a/changelog.d/0-release-notes/wpb-26771-meetings-premium.md +++ /dev/null @@ -1,9 +0,0 @@ -* The `meetingsPremium` team feature flag is **deprecated** (WPB-26771). It no - longer affects meeting behaviour: team meetings are always non-trial - regardless of its value. Its default is now **enabled and locked**, and the - Helm configuration override for `meetingsPremium` has been removed from - `charts/wire-server`. The flag's data type and its public/internal HTTP - endpoints are retained for backward compatibility but have no behavioural - effect; any Helm overrides for `meetingsPremium` are now ignored and can be - removed. The public/internal HTTP endpoints now return 404 at API version v17 - and remain available through v16; the flag type remains deprecated. The aggregate `GET /feature-configs` and `GET /teams/:tid/features` endpoints continue to include `meetingsPremium` at all API versions, including v17. diff --git a/changelog.d/0-release-notes/wpb-27175-meetings-email.md b/changelog.d/0-release-notes/wpb-27175-meetings-email.md deleted file mode 100644 index f5a5526ff19..00000000000 --- a/changelog.d/0-release-notes/wpb-27175-meetings-email.md +++ /dev/null @@ -1,11 +0,0 @@ -* Galley has a new optional `settings.meetings.email` configuration block - (WPB-27175) for sending meeting-invitation emails to invited external - addresses. It takes a required `from` sender, an optional `replyTo` address, - and a `transport` that selects AWS SES or SMTP (the same shape Brig uses). - When the block is unset, meeting invitation emails are disabled. For SMTP, - set `galley.secrets.smtpPassword` (mounted at - `/etc/wire/galley/secrets/smtp-password.txt`) and point - `settings.meetings.email.smtp.passwordFile` at that path; the Galley ConfigMap - injects it into `transport.smtpCredentials.smtpPassword`, the same pattern - Brig uses for `smtp.passwordFile`. This change adds the - configuration plumbing only; email sending itself lands in a follow-up. diff --git a/changelog.d/0-release-notes/wpb-27553-meeting-duration-tzid b/changelog.d/0-release-notes/wpb-27553-meeting-duration-tzid deleted file mode 100644 index fc465c9c94f..00000000000 --- a/changelog.d/0-release-notes/wpb-27553-meeting-duration-tzid +++ /dev/null @@ -1,6 +0,0 @@ -Starting at API version V17, the `Meeting` type returned and accepted by the -meetings endpoints carries `tzid` (IANA time zone) and drops the deprecated -`trial` field; `end_time` is retained on both V17 and V16. The operator config -`galley.config.settings.meetings.legacyTimeZone` (default `Europe/Berlin`) now -applies only to meetings created by legacy clients (API < V17); reads no longer -need it, as `tzid` is persisted `NOT NULL` (backfilled to `Europe/Berlin`). diff --git a/changelog.d/1-api-changes/WPB-23434-scim-emails-now-echo-type b/changelog.d/1-api-changes/WPB-23434-scim-emails-now-echo-type deleted file mode 100644 index 9d046cea961..00000000000 --- a/changelog.d/1-api-changes/WPB-23434-scim-emails-now-echo-type +++ /dev/null @@ -1,7 +0,0 @@ -SCIM user resources returned by spar now include `type` on stored email -addresses. Previously spar persisted no `type`, so SCIM PATCH operations with a -value-path filter like `emails[type eq "work"].value` (as sent by Microsoft -Entra ID) never matched the stored entry and silently appended a duplicate -email instead of updating the address in place. Clients that compare full SCIM -user payloads rather than individual fields (e.g. strict equality on the -`emails` array) will see the additional `type` member. (WPB-23434) diff --git a/changelog.d/1-api-changes/WPB-26626-events.md b/changelog.d/1-api-changes/WPB-26626-events.md deleted file mode 100644 index 3763b103d62..00000000000 --- a/changelog.d/1-api-changes/WPB-26626-events.md +++ /dev/null @@ -1 +0,0 @@ -Introduced meeting-specific conversation lifecycle events: `conversation.create-meeting` and `conversation.delete-meeting`. When a conversation of type meeting (`group_conv_type: "meeting"`) is created or deleted, clients receive these instead of `conversation.create` / `conversation.delete`. The payloads are identical to their non-meeting counterparts (`conversation.delete-meeting`, like `conversation.delete`, carries no `data`); only the event `type` differs, so clients can handle meetings distinctly. (WPB-26626) diff --git a/changelog.d/1-api-changes/WPB-26704 b/changelog.d/1-api-changes/WPB-26704 deleted file mode 100644 index ee9b0ef5cd7..00000000000 --- a/changelog.d/1-api-changes/WPB-26704 +++ /dev/null @@ -1 +0,0 @@ -`POST /meetings` (create) and `PUT /meetings/{domain}/{id}` (update) now return a full `conversation` object alongside the existing meeting fields. The legacy `qualified_conversation` field is retained for backward compatibility. diff --git a/changelog.d/1-api-changes/WPB-27705-roll-back_-do-_not_-include-collaborator-apps-in-get-apps-end-point b/changelog.d/1-api-changes/WPB-27705-roll-back_-do-_not_-include-collaborator-apps-in-get-apps-end-point deleted file mode 100644 index a2c92cadfba..00000000000 --- a/changelog.d/1-api-changes/WPB-27705-roll-back_-do-_not_-include-collaborator-apps-in-get-apps-end-point +++ /dev/null @@ -1 +0,0 @@ -Roll back: do *not* include collaborator apps in get-apps end-point. diff --git a/changelog.d/1-api-changes/WPB-27912-background-effects-endpoint b/changelog.d/1-api-changes/WPB-27912-background-effects-endpoint deleted file mode 100644 index e7c75238edd..00000000000 --- a/changelog.d/1-api-changes/WPB-27912-background-effects-endpoint +++ /dev/null @@ -1,7 +0,0 @@ -The `backgroundEffects` team feature endpoints are deprecated and return 404 for -clients on API version v17: the public `GET`/`PUT /teams/:tid/features/backgroundEffects` -and the internal legacy lock `PUT /i/teams/:tid/features/backgroundEffects/(un)?locked`. -They remain available through v16. The aggregate endpoints -`GET /feature-configs` and `GET /teams/:tid/features` are unaffected and continue -to include `backgroundEffects` at all API versions: the aggregate feature list is -version-agnostic, like other version-gated features such as MLS. diff --git a/changelog.d/1-api-changes/WPB-27953-make-scim-error-responses-comply-with-rfc7644 b/changelog.d/1-api-changes/WPB-27953-make-scim-error-responses-comply-with-rfc7644 deleted file mode 100644 index f03aeea44f6..00000000000 --- a/changelog.d/1-api-changes/WPB-27953-make-scim-error-responses-comply-with-rfc7644 +++ /dev/null @@ -1 +0,0 @@ -Make scim error responses comply with RFC7644. diff --git a/changelog.d/1-api-changes/WPB-27953-scim_-advertise-all-schemas-used-in-user-_fixes-rfc-compliance-issue_ b/changelog.d/1-api-changes/WPB-27953-scim_-advertise-all-schemas-used-in-user-_fixes-rfc-compliance-issue_ deleted file mode 100644 index 0bf2f98a733..00000000000 --- a/changelog.d/1-api-changes/WPB-27953-scim_-advertise-all-schemas-used-in-user-_fixes-rfc-compliance-issue_ +++ /dev/null @@ -1 +0,0 @@ -SCIM: advertise all schemas used in User (fixes RFC compliance issue). diff --git a/changelog.d/1-api-changes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc b/changelog.d/1-api-changes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc deleted file mode 100644 index d9112b27dd6..00000000000 --- a/changelog.d/1-api-changes/WPB-27953-scim_-make-role-field-in-user-schema-comply-with-rfc +++ /dev/null @@ -1 +0,0 @@ -SCIM: Make role and entitlements fields in user schema comply with RFC. diff --git a/changelog.d/1-api-changes/WPB-28028-mlsmigration-allowManualMigration.md b/changelog.d/1-api-changes/WPB-28028-mlsmigration-allowManualMigration.md deleted file mode 100644 index 7a745fdf82d..00000000000 --- a/changelog.d/1-api-changes/WPB-28028-mlsmigration-allowManualMigration.md +++ /dev/null @@ -1,5 +0,0 @@ -The `mlsMigration` team feature config now includes an `allowManualMigration` -boolean field (default `false`) that controls whether clients are permitted to -perform single-group (manual) MLS migrations. The field only steers client -behaviour (e.g. if a migration button is shown or not). It does not enforce -checks in the backend. diff --git a/changelog.d/1-api-changes/WPB-28050-new-oauth-scopes-for-meetings-for-google-calendar-integration b/changelog.d/1-api-changes/WPB-28050-new-oauth-scopes-for-meetings-for-google-calendar-integration deleted file mode 100644 index fcef459a645..00000000000 --- a/changelog.d/1-api-changes/WPB-28050-new-oauth-scopes-for-meetings-for-google-calendar-integration +++ /dev/null @@ -1 +0,0 @@ -New oauth scopes for meetings for calendar integration. diff --git a/changelog.d/1-api-changes/WPB-28083-meeting-errors-exposed b/changelog.d/1-api-changes/WPB-28083-meeting-errors-exposed deleted file mode 100644 index 43819d29ae9..00000000000 --- a/changelog.d/1-api-changes/WPB-28083-meeting-errors-exposed +++ /dev/null @@ -1 +0,0 @@ -Meeting endpoints (`POST /meetings`, `PUT/DELETE /meetings/:domain/:id`, meeting invitation endpoints): errors have dedicated descriptions. diff --git a/changelog.d/1-api-changes/WPB-28272 b/changelog.d/1-api-changes/WPB-28272 deleted file mode 100644 index 1619e6df99f..00000000000 --- a/changelog.d/1-api-changes/WPB-28272 +++ /dev/null @@ -1,2 +0,0 @@ -`PUT /meetings/{domain}/{id}` now accepts an optional `tzid` field to update a -meeting's IANA time zone. diff --git a/changelog.d/1-api-changes/WPB-28299-finalize-api-version-17 b/changelog.d/1-api-changes/WPB-28299-finalize-api-version-17 deleted file mode 100644 index fbdd04615a8..00000000000 --- a/changelog.d/1-api-changes/WPB-28299-finalize-api-version-17 +++ /dev/null @@ -1 +0,0 @@ -Finalize api version 17. diff --git a/changelog.d/1-api-changes/multi-ingress-mandatory-allowlist b/changelog.d/1-api-changes/multi-ingress-mandatory-allowlist deleted file mode 100644 index 444a67a31e5..00000000000 --- a/changelog.d/1-api-changes/multi-ingress-mandatory-allowlist +++ /dev/null @@ -1,7 +0,0 @@ -To prevent security-relevant configuration mistakes, make configuration of -allowed IdP certificate fingerprints (`idpCertFingerprintAllowlist`) mandatory for -multi-ingress SSO. **This will break existing multi-ingress SSO flows until -`idpCertFingerprintAllowlist` is configured!** This breakage is unfortunately -necessary, because we're getting more lenient regarding the IdPs a user can use -to log in ("auto IdP migration"). Regular (non-multi-ingress) use cases are -unaffected. diff --git a/changelog.d/1-api-changes/wpb-26626-meeting-legacy-404 b/changelog.d/1-api-changes/wpb-26626-meeting-legacy-404 deleted file mode 100644 index 3cdf8fa9758..00000000000 --- a/changelog.d/1-api-changes/wpb-26626-meeting-legacy-404 +++ /dev/null @@ -1 +0,0 @@ -+`GET /conversations/{domain}/{id}` and the legacy `GET /conversations/{id}` now return 404 (`no-conversation`) when the conversation is a meeting, on API versions prior to V16, instead of returning the conversation with `group_conv_type: null`. The legacy batch endpoint `GET /conversations?ids=…` — itself removed at V3, so only ever available on V1–V2 — likewise omits meeting conversations from its results. Meeting conversations remain fully accessible from V16 onwards. (WPB-26626) diff --git a/changelog.d/1-api-changes/wpb-26771-meetings-premium-endpoint.md b/changelog.d/1-api-changes/wpb-26771-meetings-premium-endpoint.md deleted file mode 100644 index b8cb1c77871..00000000000 --- a/changelog.d/1-api-changes/wpb-26771-meetings-premium-endpoint.md +++ /dev/null @@ -1,8 +0,0 @@ -The `meetingsPremium` team feature endpoints are deprecated and return 404 for -clients on API version v17: the public `GET`/`PUT /teams/:tid/features/meetingsPremium` -and the internal legacy lock `PUT /i/teams/:tid/features/meetingsPremium/(un)?locked`. -They remain available through v16. The flag has had no behavioural effect since -WPB-26771 (team meetings are always non-trial). The aggregate endpoints -`GET /feature-configs` and `GET /teams/:tid/features` are unaffected and continue -to include `meetingsPremium` at all API versions: the aggregate feature list is -version-agnostic, like other version-gated features such as MLS. (WPB-26771) diff --git a/changelog.d/1-api-changes/wpb-26773-meeting-start-not-past b/changelog.d/1-api-changes/wpb-26773-meeting-start-not-past deleted file mode 100644 index 3116adbf64f..00000000000 --- a/changelog.d/1-api-changes/wpb-26773-meeting-start-not-past +++ /dev/null @@ -1 +0,0 @@ -Reject meeting creation and update when the start time is in the past (with a 60-second tolerance for clock skew). Previously, meetings could be created with arbitrary past start times. diff --git a/changelog.d/1-api-changes/wpb-27329-meetings-read-disabled b/changelog.d/1-api-changes/wpb-27329-meetings-read-disabled deleted file mode 100644 index db3e2cfc83d..00000000000 --- a/changelog.d/1-api-changes/wpb-27329-meetings-read-disabled +++ /dev/null @@ -1 +0,0 @@ -GET /meetings/list and GET /meetings/{domain}/{id} no longer return 403 invalid-op when the caller's team has the `meetings` feature disabled. The read endpoints now treat a disabled feature as "no meetings": GET /meetings/list returns 200 [], and GET /meetings/{domain}/{id} returns 404 meeting-not-found. Write operations (create, update, delete, invitation mutations) still return 403 invalid-op when the feature is disabled. Previously these read endpoints returned an undocumented 403 invalid-op for members of teams with the meetings feature disabled. diff --git a/changelog.d/1-api-changes/wpb-27373-meeting-drop-trial b/changelog.d/1-api-changes/wpb-27373-meeting-drop-trial deleted file mode 100644 index 7b7300f58a6..00000000000 --- a/changelog.d/1-api-changes/wpb-27373-meeting-drop-trial +++ /dev/null @@ -1 +0,0 @@ -The meetings endpoints (POST /meetings, PUT /meetings/{domain}/{id}, GET /meetings/{domain}/{id}, GET /meetings/list) drop the deprecated `trial` field from the `Meeting` response starting at API version V17. On V15–V16 the field is still present but always returns `false` (team meetings are never trial; see WPB-26771). The underlying storage is unchanged. diff --git a/changelog.d/1-api-changes/wpb-27465-meeting-update-ongoing b/changelog.d/1-api-changes/wpb-27465-meeting-update-ongoing deleted file mode 100644 index 5eed2b98024..00000000000 --- a/changelog.d/1-api-changes/wpb-27465-meeting-update-ongoing +++ /dev/null @@ -1 +0,0 @@ -PUT /meetings/{domain}/{id} can now edit a meeting that has already started (an ongoing meeting). The start-time-not-in-the-past validation added by WPB-26773 previously rejected any update whose start_time was in the past, which also blocked legitimate edits to ongoing meetings — whose start time is naturally in the past; the check now applies only to meetings that have not started yet. Creating a meeting with a past start time, and moving an upcoming meeting's start time into the past, remain rejected (WPB-27465). diff --git a/changelog.d/1-api-changes/wpb-27553-meeting-duration-tzid b/changelog.d/1-api-changes/wpb-27553-meeting-duration-tzid deleted file mode 100644 index 0163620aa55..00000000000 --- a/changelog.d/1-api-changes/wpb-27553-meeting-duration-tzid +++ /dev/null @@ -1,4 +0,0 @@ -Starting at API version V17, the `Meeting` type carries a `tzid` (IANA time -zone) and drops the deprecated `trial` field; `end_time` is retained on both V17 -and V16. A V17 update that supplies only `start_time` leaves `end_time` -unchanged — pass `end_time` to reschedule the end. diff --git a/changelog.d/2-features/WPB-23177 b/changelog.d/2-features/WPB-23177 deleted file mode 100644 index 492ac9a01d9..00000000000 --- a/changelog.d/2-features/WPB-23177 +++ /dev/null @@ -1 +0,0 @@ -Manual team invitations now conflict when a matching pending SCIM invitation already exists for the same team and email address. diff --git a/changelog.d/2-features/WPB-26101 b/changelog.d/2-features/WPB-26101 deleted file mode 100644 index 03d7aede22f..00000000000 --- a/changelog.d/2-features/WPB-26101 +++ /dev/null @@ -1 +0,0 @@ -Add user contact-status enrichment to user listings based on available Proteus and MLS contact methods. diff --git a/changelog.d/2-features/WPB-26489 b/changelog.d/2-features/WPB-26489 deleted file mode 100644 index 542c6831057..00000000000 --- a/changelog.d/2-features/WPB-26489 +++ /dev/null @@ -1 +0,0 @@ -Introduce schedulable background jobs, migrate meetings cleanup to the new job runner, and add the initial adminless reminder and deletion jobs. diff --git a/changelog.d/2-features/WPB-26650 b/changelog.d/2-features/WPB-26650 deleted file mode 100644 index d033ec48bda..00000000000 --- a/changelog.d/2-features/WPB-26650 +++ /dev/null @@ -1 +0,0 @@ -Skip senderless prevent-adminless deletion for federated conversations with remote members to prevent remote state drift. diff --git a/changelog.d/2-features/WPB-27017 b/changelog.d/2-features/WPB-27017 deleted file mode 100644 index cfb5944d2d5..00000000000 --- a/changelog.d/2-features/WPB-27017 +++ /dev/null @@ -1 +0,0 @@ -Add adminless-group reconciliation, teardown, and system events for member updates, reminders, and deletion. (#5357, #5390) diff --git a/changelog.d/2-features/WPB-27907-meeting-notifications-alignment b/changelog.d/2-features/WPB-27907-meeting-notifications-alignment deleted file mode 100644 index 01a8285f755..00000000000 --- a/changelog.d/2-features/WPB-27907-meeting-notifications-alignment +++ /dev/null @@ -1 +0,0 @@ -Filter Wire Meetings lifecycle events only on the originating client connection. diff --git a/changelog.d/2-features/multi-ingress-cross-IdP-SSO b/changelog.d/2-features/multi-ingress-cross-IdP-SSO deleted file mode 100644 index c656b74b546..00000000000 --- a/changelog.d/2-features/multi-ingress-cross-IdP-SSO +++ /dev/null @@ -1,10 +0,0 @@ -When a team uses multiple SAML IdPs (one per ingress domain) in a multi-ingress -setup, users can now authenticate via any of the team's IdPs even if their -account was originally provisioned under a different one. Spar resolves the -correct account by email-based NameID lookup across all team IdPs and migrates -the user's SSO identity to the authenticating IdP transparently. - -**Important:** Email addresses (`NameID`s) must be unique across configured -IdPs! Otherwise, users may be logged into wrong accounts! - -Please refer to the documentation for further information. diff --git a/changelog.d/2-features/wpb-26705-meeting-events.md b/changelog.d/2-features/wpb-26705-meeting-events.md deleted file mode 100644 index ab7928a5020..00000000000 --- a/changelog.d/2-features/wpb-26705-meeting-events.md +++ /dev/null @@ -1,8 +0,0 @@ -* Added meeting lifecycle events: `meeting.create`, `meeting.update`, and - `meeting.delete` (WPB-26705). These websocket notifications are pushed to all - local members of the meeting's conversation on every successful create, update, - and delete operation. Each payload carries the event `type`, the meeting's - qualified ID in the top-level `qualified_id` field, the - `qualified_conversation`, `qualified_from`, `via`, `time`, and optional `team`. - Meeting events use a dedicated event envelope (not the conversation event - envelope). diff --git a/changelog.d/2-features/wpb-27620-meeting-member-add.md b/changelog.d/2-features/wpb-27620-meeting-member-add.md deleted file mode 100644 index e87cc9b706b..00000000000 --- a/changelog.d/2-features/wpb-27620-meeting-member-add.md +++ /dev/null @@ -1,5 +0,0 @@ -Added `meeting.member-add` websocket event (WPB-27620). When a user becomes a -member of an MLS meeting conversation, a `meeting.member-add` lifecycle event is -pushed to the newly-added local members, alongside the existing `meeting.create`, -`meeting.update`, and `meeting.delete` events. The payload uses the same meeting -event structure as the other meeting lifecycle events. diff --git a/changelog.d/3-bug-fixes/WPB-21744 b/changelog.d/3-bug-fixes/WPB-21744 deleted file mode 100644 index 3825ca54830..00000000000 --- a/changelog.d/3-bug-fixes/WPB-21744 +++ /dev/null @@ -1 +0,0 @@ -When a user is put under SCIM control, any pending email-address update is now invalidated (the unvalidated email and its activation token are removed). Previously, team settings kept offering a "resend verification" action that could not succeed (failing with `403 managed-by-scim`), and a stale activation link could still change a SCIM-managed user's email outside of SCIM. diff --git a/changelog.d/3-bug-fixes/WPB-23177 b/changelog.d/3-bug-fixes/WPB-23177 deleted file mode 100644 index b52e4bd670b..00000000000 --- a/changelog.d/3-bug-fixes/WPB-23177 +++ /dev/null @@ -1 +0,0 @@ -Release a handle claimed after a SCIM invitation expired, before cleanup, preventing a subsequent team invitation from using that handle. diff --git a/changelog.d/3-bug-fixes/WPB-23434 b/changelog.d/3-bug-fixes/WPB-23434 deleted file mode 100644 index 626855dbce1..00000000000 --- a/changelog.d/3-bug-fixes/WPB-23434 +++ /dev/null @@ -1,4 +0,0 @@ -SCIM PATCH now supports the `emails` multi-valued attribute (e.g. Entra's -`emails[type eq "work"].value`), so user emails can be updated via SCIM. Identity -providers that previously hit a `can not lens into multi-valued attributes yet` -error when provisioning emails now succeed. diff --git a/changelog.d/3-bug-fixes/WPB-23434-email-type b/changelog.d/3-bug-fixes/WPB-23434-email-type deleted file mode 100644 index 395787c37c1..00000000000 --- a/changelog.d/3-bug-fixes/WPB-23434-email-type +++ /dev/null @@ -1,13 +0,0 @@ -SCIM email metadata is now persisted and echoed verbatim. spar stores the -`type` and `primary` sub-attributes of the SCIM email entry it keeps (in -`spar.scim_user_times`) and echoes them back on GET/POST/PATCH exactly as the -IdP sent them, instead of synthesizing a hardcoded `type` of `"work"` (email -`type` values are limited to 64 characters). As a result, PATCH value-path -filters like Entra's `emails[type eq "work"].value` and Okta's -`emails[primary eq true].value` match the stored entry for an in-place update -when (and only when) the IdP actually supplied that metadata at provisioning -time; users provisioned without it echo neither field. Since per RFC 7644 -§3.5.2 an `Add` on a non-existing target creates it, a type-filter PATCH -against a user whose stored email carries no such metadata appends a new -entry (which the single-email reduction collapses back to the old address, -i.e. no visible change), while a primary-filter PATCH is a complete no-op. diff --git a/changelog.d/3-bug-fixes/WPB-23434-multi-primary b/changelog.d/3-bug-fixes/WPB-23434-multi-primary deleted file mode 100644 index 737c51773e1..00000000000 --- a/changelog.d/3-bug-fixes/WPB-23434-multi-primary +++ /dev/null @@ -1,4 +0,0 @@ -SCIM user provisioning now rejects requests (HTTP 400) that mark more than one -email as `primary`, an RFC 7643 §2.4 violation. Previously spar silently picked -one primary and dropped the rest, masking client-side misconfiguration. Requests -with zero or one primary email are unchanged. diff --git a/changelog.d/3-bug-fixes/WPB-24669 b/changelog.d/3-bug-fixes/WPB-24669 deleted file mode 100644 index fc94cf60f77..00000000000 --- a/changelog.d/3-bug-fixes/WPB-24669 +++ /dev/null @@ -1 +0,0 @@ -Fixed asset uploads with non-ASCII filenames when audit logging is enabled. Audit-log metadata is now percent-encoded before being stored in S3 metadata headers and decoded when read back. diff --git a/changelog.d/3-bug-fixes/WPB-25521-finish-collaborator-crud-api b/changelog.d/3-bug-fixes/WPB-25521-finish-collaborator-crud-api deleted file mode 100644 index a692802fd55..00000000000 --- a/changelog.d/3-bug-fixes/WPB-25521-finish-collaborator-crud-api +++ /dev/null @@ -1 +0,0 @@ -Fix nginz routes for delete / update collaborator. Move collaborator CRUD api to galley. diff --git a/changelog.d/3-bug-fixes/WPB-25544 b/changelog.d/3-bug-fixes/WPB-25544 deleted file mode 100644 index 14a7d1a7de8..00000000000 --- a/changelog.d/3-bug-fixes/WPB-25544 +++ /dev/null @@ -1 +0,0 @@ -Users marked non-searchable are no longer returned across federation. diff --git a/changelog.d/3-bug-fixes/WPB-25579-if-apps-are-re-enabled-in-the-team_-do-not-re-activate-any-apps-in-the-team b/changelog.d/3-bug-fixes/WPB-25579-if-apps-are-re-enabled-in-the-team_-do-not-re-activate-any-apps-in-the-team deleted file mode 100644 index 048128dcb28..00000000000 --- a/changelog.d/3-bug-fixes/WPB-25579-if-apps-are-re-enabled-in-the-team_-do-not-re-activate-any-apps-in-the-team +++ /dev/null @@ -1 +0,0 @@ -If apps are re-enabled in the team, DO NOT re-activate any apps in the team. diff --git a/changelog.d/3-bug-fixes/WPB-27857 b/changelog.d/3-bug-fixes/WPB-27857 deleted file mode 100644 index e0bd5f68c36..00000000000 --- a/changelog.d/3-bug-fixes/WPB-27857 +++ /dev/null @@ -1 +0,0 @@ -Wire Meetings lifecycle events (meeting.create, meeting.update, meeting.delete) are no longer delivered to the user who triggered the action, consistent with how other event types avoid echoing back to the originator. Previously the creator/updater/deleter received their own meeting event. diff --git a/changelog.d/3-bug-fixes/WPB-28083-allow-team-admin-to-remove-bot-from-all-conversations_-instead-of-crashing b/changelog.d/3-bug-fixes/WPB-28083-allow-team-admin-to-remove-bot-from-all-conversations_-instead-of-crashing deleted file mode 100644 index e9dc36e8864..00000000000 --- a/changelog.d/3-bug-fixes/WPB-28083-allow-team-admin-to-remove-bot-from-all-conversations_-instead-of-crashing +++ /dev/null @@ -1 +0,0 @@ -Allow team admin to remove bot from all conversations, instead of crashing. (This is how it's already done in 'finishDeleteService'.) diff --git a/changelog.d/3-bug-fixes/WPB-28132-fix-openapi3-docs-for-oauth-scopes b/changelog.d/3-bug-fixes/WPB-28132-fix-openapi3-docs-for-oauth-scopes deleted file mode 100644 index a732d0fc5e9..00000000000 --- a/changelog.d/3-bug-fixes/WPB-28132-fix-openapi3-docs-for-oauth-scopes +++ /dev/null @@ -1 +0,0 @@ -Fix openapi3 docs for oauth scopes. diff --git a/changelog.d/3-bug-fixes/ephemeral-user-claim-key-package b/changelog.d/3-bug-fixes/ephemeral-user-claim-key-package deleted file mode 100644 index d28c848f235..00000000000 --- a/changelog.d/3-bug-fixes/ephemeral-user-claim-key-package +++ /dev/null @@ -1 +0,0 @@ -Enable claiming key packages for ephemeral users diff --git a/changelog.d/3-bug-fixes/federator-internal-status-cross-check b/changelog.d/3-bug-fixes/federator-internal-status-cross-check deleted file mode 100644 index 9e910dc833c..00000000000 --- a/changelog.d/3-bug-fixes/federator-internal-status-cross-check +++ /dev/null @@ -1 +0,0 @@ -The federator internal listener's /i/status health check now correctly verifies the external listener is ready instead of checking itself, so readiness no longer reports up before the external federation listener is bound. diff --git a/changelog.d/3-bug-fixes/fix-sso-get-by-email-rate-limiting b/changelog.d/3-bug-fixes/fix-sso-get-by-email-rate-limiting deleted file mode 100644 index a087364dee6..00000000000 --- a/changelog.d/3-bug-fixes/fix-sso-get-by-email-rate-limiting +++ /dev/null @@ -1,5 +0,0 @@ -Reorder SSO nginx locations to enforce correct rate limiting: -`/sso/get-by-email` needs to appear before `/sso` in nginx's config, because -regex locations are matched in order (first-match), not by specificity. In -previous order `/sso` caught before `/sso/get-by-email` applied the specific -5r/m rate limit, leaving it on the generic 50r/s limit. diff --git a/changelog.d/3-bug-fixes/multi-ingress-csp-host-scoping b/changelog.d/3-bug-fixes/multi-ingress-csp-host-scoping deleted file mode 100644 index a0ddaebe61e..00000000000 --- a/changelog.d/3-bug-fixes/multi-ingress-csp-host-scoping +++ /dev/null @@ -1 +0,0 @@ -Fixed Content Security Policy header scoping in multi-ingress Kubernetes configuration. CSP headers set via the Ingress nginx configuration-snippet are now properly scoped to exclude the webapp domain, preventing conflicts with the webapp's own CSP headers that are set independently. diff --git a/changelog.d/3-bug-fixes/reject-duplicate-handles b/changelog.d/3-bug-fixes/reject-duplicate-handles deleted file mode 100644 index 4b9a627afb9..00000000000 --- a/changelog.d/3-bug-fixes/reject-duplicate-handles +++ /dev/null @@ -1,4 +0,0 @@ -SCIM: Avoid assigning an already claimed handle to a user. - -Before this if SCIM created a user with a handle already claimed by another user -the handle would get stored for the user even if the overall SCIM call fails. \ No newline at end of file diff --git a/changelog.d/3-bug-fixes/scim-email-subattr-remove-null b/changelog.d/3-bug-fixes/scim-email-subattr-remove-null deleted file mode 100644 index 88ef89e88fe..00000000000 --- a/changelog.d/3-bug-fixes/scim-email-subattr-remove-null +++ /dev/null @@ -1,13 +0,0 @@ -SCIM PATCH on `emails` now handles the email `type` and `primary` -sub-attributes per RFC 7644 §3.5.2.2 and RFC 7643 §2.5: -`remove emails[type eq "work"].type` (or `.primary`) unassigns just that -sub-attribute and keeps the entry (including the address and the other -sub-attribute) instead of deleting the whole record; `replace`/`add` with an -explicit `"value": null` unassigns the sub-attribute the same way; and a -filterless `remove emails` clears all email entries. Removing or nulling the -`.value` sub-attribute is rejected with a 400 pointing at whole-entry removal, -since the address is the record's identity. As a consequence of preserving -explicit `null` values in PATCH operations, `replace` with `null` on -`displayName`/`externalId`/`active` now unassigns the attribute (RFC 7643 §2.5) -like the corresponding `remove` already did, instead of failing with -"No value was provided". diff --git a/changelog.d/3-bug-fixes/search-visibility-feature-key b/changelog.d/3-bug-fixes/search-visibility-feature-key deleted file mode 100644 index e8afbcf1ccc..00000000000 --- a/changelog.d/3-bug-fixes/search-visibility-feature-key +++ /dev/null @@ -1 +0,0 @@ -Fixed a discrepancy between the canonical `searchVisibility` feature key used in runtime JSON and the legacy `teamSearchVisibility` key used in YAML configuration. Both names are now accepted; no configuration changes or other operator actions are required. diff --git a/changelog.d/5-internal/WPB---fix-integration-test-cleanup-on-federation-instance-v2 b/changelog.d/5-internal/WPB---fix-integration-test-cleanup-on-federation-instance-v2 deleted file mode 100644 index 3cd02e782f8..00000000000 --- a/changelog.d/5-internal/WPB---fix-integration-test-cleanup-on-federation-instance-v2 +++ /dev/null @@ -1 +0,0 @@ -Fix integration test cleanup on federation instance V2. diff --git a/changelog.d/5-internal/WPB---update-postgres-schema-dump b/changelog.d/5-internal/WPB---update-postgres-schema-dump deleted file mode 100644 index 218f9c7ed9e..00000000000 --- a/changelog.d/5-internal/WPB---update-postgres-schema-dump +++ /dev/null @@ -1 +0,0 @@ -Update postgres schema dump. diff --git a/changelog.d/5-internal/WPB-18127-move-email-template-completeness-tests-from-brig-to-wire-subsystems b/changelog.d/5-internal/WPB-18127-move-email-template-completeness-tests-from-brig-to-wire-subsystems deleted file mode 100644 index 8e334bf6bbf..00000000000 --- a/changelog.d/5-internal/WPB-18127-move-email-template-completeness-tests-from-brig-to-wire-subsystems +++ /dev/null @@ -1 +0,0 @@ -Move email template completeness tests from brig to wire-subsystems. diff --git a/changelog.d/5-internal/WPB-23434-scim-user-meta-store b/changelog.d/5-internal/WPB-23434-scim-user-meta-store deleted file mode 100644 index bef5b88d891..00000000000 --- a/changelog.d/5-internal/WPB-23434-scim-user-meta-store +++ /dev/null @@ -1 +0,0 @@ -Rename `Wire.ScimUserTimesStore` to `Wire.ScimUserMetaStore` (`ScimUserTimes` -> `ScimUserMeta`); the store also holds SCIM email metadata now. The Cassandra table `spar.scim_user_times` is unchanged. diff --git a/changelog.d/5-internal/WPB-23631-0 b/changelog.d/5-internal/WPB-23631-0 deleted file mode 100644 index feb36540c11..00000000000 --- a/changelog.d/5-internal/WPB-23631-0 +++ /dev/null @@ -1 +0,0 @@ -Move `Spar.Sem.Reporter` to `Wire.Reporter` diff --git a/changelog.d/5-internal/WPB-23631-1 b/changelog.d/5-internal/WPB-23631-1 deleted file mode 100644 index 8782ca6afa7..00000000000 --- a/changelog.d/5-internal/WPB-23631-1 +++ /dev/null @@ -1 +0,0 @@ -Move `Spar.Sem.SamlProtocolSettings` to `Wire.SamlProtocolSettings` diff --git a/changelog.d/5-internal/WPB-23631-10 b/changelog.d/5-internal/WPB-23631-10 deleted file mode 100644 index 20fcd06f292..00000000000 --- a/changelog.d/5-internal/WPB-23631-10 +++ /dev/null @@ -1 +0,0 @@ -Move `Brig.Effects.JwtTools` in `Wire.JwtTools`. diff --git a/changelog.d/5-internal/WPB-23631-11 b/changelog.d/5-internal/WPB-23631-11 deleted file mode 100644 index aaebb0e4d24..00000000000 --- a/changelog.d/5-internal/WPB-23631-11 +++ /dev/null @@ -1 +0,0 @@ -Move `Brig.Budget` in `Wire.BudgetStore`. diff --git a/changelog.d/5-internal/WPB-23631-12 b/changelog.d/5-internal/WPB-23631-12 deleted file mode 100644 index ce8678bc538..00000000000 --- a/changelog.d/5-internal/WPB-23631-12 +++ /dev/null @@ -1 +0,0 @@ -Move `Galley.Effects.Queue` in `Wire.BoundedQueue`. diff --git a/changelog.d/5-internal/WPB-23631-2 b/changelog.d/5-internal/WPB-23631-2 deleted file mode 100644 index 900550a2839..00000000000 --- a/changelog.d/5-internal/WPB-23631-2 +++ /dev/null @@ -1 +0,0 @@ -Move `Spar.Sem.ScimUserTimesStore` to `Wire.ScimUserTimesStore`. diff --git a/changelog.d/5-internal/WPB-23631-3 b/changelog.d/5-internal/WPB-23631-3 deleted file mode 100644 index 17144f08e16..00000000000 --- a/changelog.d/5-internal/WPB-23631-3 +++ /dev/null @@ -1 +0,0 @@ -Move `Spar.Sem.DefaultSsoCode` in `Wire.DefaultSsoStore` diff --git a/changelog.d/5-internal/WPB-23631-4 b/changelog.d/5-internal/WPB-23631-4 deleted file mode 100644 index 3fa09714c3c..00000000000 --- a/changelog.d/5-internal/WPB-23631-4 +++ /dev/null @@ -1 +0,0 @@ -Move `Spar.Sem.IdPRawMetadataStore` to `Wire.IdPRawMetadataStore`. diff --git a/changelog.d/5-internal/WPB-23631-5 b/changelog.d/5-internal/WPB-23631-5 deleted file mode 100644 index 6202f2f46bc..00000000000 --- a/changelog.d/5-internal/WPB-23631-5 +++ /dev/null @@ -1 +0,0 @@ -Move `Spar.Sem.VerdictFormatStore` in `Wire.VerdictFormatStore`. diff --git a/changelog.d/5-internal/WPB-23631-6 b/changelog.d/5-internal/WPB-23631-6 deleted file mode 100644 index 1d5fd9d1ddf..00000000000 --- a/changelog.d/5-internal/WPB-23631-6 +++ /dev/null @@ -1 +0,0 @@ -Move `Spar.Sem.ScimExternalIdStore` in `Wire.ScimExternalIdStore`. (#5392, #5423) diff --git a/changelog.d/5-internal/WPB-23631-7 b/changelog.d/5-internal/WPB-23631-7 deleted file mode 100644 index 4cbfcb9e847..00000000000 --- a/changelog.d/5-internal/WPB-23631-7 +++ /dev/null @@ -1,2 +0,0 @@ -Read the DPoP public key bundle once at startup and cache it, instead of -re-reading the file on every request; the `PublicKeyBundle` effect is removed. diff --git a/changelog.d/5-internal/WPB-23631-8 b/changelog.d/5-internal/WPB-23631-8 deleted file mode 100644 index 78bf843b4a5..00000000000 --- a/changelog.d/5-internal/WPB-23631-8 +++ /dev/null @@ -1 +0,0 @@ -Move `Brig.Effects.UserPendingActivationStore` in `Wire.UserPendingActivationStore`. diff --git a/changelog.d/5-internal/WPB-23631-9 b/changelog.d/5-internal/WPB-23631-9 deleted file mode 100644 index c92b91d0427..00000000000 --- a/changelog.d/5-internal/WPB-23631-9 +++ /dev/null @@ -1 +0,0 @@ -Move `Brig.Effects.SFT` in `Wire.SFT`. diff --git a/changelog.d/5-internal/WPB-25475-multi-ingress-annotations b/changelog.d/5-internal/WPB-25475-multi-ingress-annotations deleted file mode 100644 index 8c4467f57ee..00000000000 --- a/changelog.d/5-internal/WPB-25475-multi-ingress-annotations +++ /dev/null @@ -1,4 +0,0 @@ -In multi-domain (multi-ingress) mode, the `wire-ingress` chart now applies the -`httpRoute.annotations` passthrough to every per-domain HTTPRoute, so external-dns -weighted-record annotations (set-identifier/aws-weight) work across all backend -domains. The `service.create` toggle also applies in multi-domain deployments. diff --git a/changelog.d/5-internal/WPB-26101 b/changelog.d/5-internal/WPB-26101 deleted file mode 100644 index 9cf9e14fcb2..00000000000 --- a/changelog.d/5-internal/WPB-26101 +++ /dev/null @@ -1 +0,0 @@ -Extract MLS key-package handling into a subsystem and split Cassandra access into a dedicated store. diff --git a/changelog.d/5-internal/WPB-26823 b/changelog.d/5-internal/WPB-26823 deleted file mode 100644 index 67e387b6b27..00000000000 --- a/changelog.d/5-internal/WPB-26823 +++ /dev/null @@ -1,10 +0,0 @@ -Added partial indexes and rewrote the meetings cleanup query so the background -worker stays fully index-backed as the `meetings` table grows: -- `idx_meetings_recurrence_eff_end` on `GREATEST(end_time, recurrence_until)` - for bounded recurring meetings (covers the recurring branches of the list and - cleanup queries). -- `idx_meetings_end_time_nonrecurring` on `end_time` for non-recurring meetings, - so cleanup can find expired non-recurring meetings without scanning - not-yet-expired recurring rows whose original slot is long past. -`getOldMeetingsImpl` now issues one bounded, index-backed query per meeting kind -and merges the two batches. diff --git a/changelog.d/5-internal/WPB-26823-recurrence-constraint b/changelog.d/5-internal/WPB-26823-recurrence-constraint deleted file mode 100644 index 00cdc117968..00000000000 --- a/changelog.d/5-internal/WPB-26823-recurrence-constraint +++ /dev/null @@ -1,4 +0,0 @@ -Added a `meetings_recurrence_consistency` CHECK constraint so the recurrence -columns can never be left in a partially-set state (frequency is the master -switch; interval is required when set; recurrence_until stays optional for -open-ended recurring meetings). diff --git a/changelog.d/5-internal/WPB-27162-wire-ingress-external-dns-annotations b/changelog.d/5-internal/WPB-27162-wire-ingress-external-dns-annotations deleted file mode 100644 index b84d4f81c4f..00000000000 --- a/changelog.d/5-internal/WPB-27162-wire-ingress-external-dns-annotations +++ /dev/null @@ -1,6 +0,0 @@ -The wire-ingress and nginx-ingress-services charts now expose annotation -passthroughs (`httpRoute.annotations` on the HTTPRoutes, `ingress.annotations` on -the Ingresses), so external-dns weighted records (set-identifier/aws-weight) can -be attached to both routers for a zero-downtime nginx-to-Envoy DNS cutover. -wire-ingress also gains a `service.create` toggle (default true) to reuse the -backend Services owned by nginx-ingress-services while both run in parallel. diff --git a/changelog.d/5-internal/WPB-27169-make-haddocks-more-readable b/changelog.d/5-internal/WPB-27169-make-haddocks-more-readable deleted file mode 100644 index ccada72ee70..00000000000 --- a/changelog.d/5-internal/WPB-27169-make-haddocks-more-readable +++ /dev/null @@ -1 +0,0 @@ -Make haddocks more readable. diff --git a/changelog.d/5-internal/WPB-27370-envoy-logs b/changelog.d/5-internal/WPB-27370-envoy-logs deleted file mode 100644 index ebf2b02b8a6..00000000000 --- a/changelog.d/5-internal/WPB-27370-envoy-logs +++ /dev/null @@ -1 +0,0 @@ -When using the wire-ingress provided EnvoyProxy, ensure the request path's query string is removed from the logs, to ensure access_tokens passed as query string are not logged. Browsers are required to pass them on a query string on some endpoints such as /await. diff --git a/changelog.d/5-internal/WPB-28163-move-v17-endpoints-to-v18 b/changelog.d/5-internal/WPB-28163-move-v17-endpoints-to-v18 deleted file mode 100644 index 91dc14e9ff5..00000000000 --- a/changelog.d/5-internal/WPB-28163-move-v17-endpoints-to-v18 +++ /dev/null @@ -1 +0,0 @@ -Moved the following endpoints from development version V17 to the new development version V18: `PUT /conversations/:domain/:cnv/members` (rejection of replacements that would leave a group adminless), `PUT /teams/:tid/features/preventAdminlessGroups` (duration-string request body), and `POST /register` (403 for SCIM-managed users changing their name). V17 behaves like V16 for these endpoints. API version V18 was created as a development version; V17 remains a development version until finalized. The changelog entry for the moved members endpoint is parked in `changelog.d/99-pending/`, which `mk-changelog.sh` and `mk-cleanup.sh` now skip. diff --git a/changelog.d/5-internal/WPB-28280-automate-license-header-updates-in-treefmt b/changelog.d/5-internal/WPB-28280-automate-license-header-updates-in-treefmt deleted file mode 100644 index 67c229811fa..00000000000 --- a/changelog.d/5-internal/WPB-28280-automate-license-header-updates-in-treefmt +++ /dev/null @@ -1 +0,0 @@ -Automate license header updates in treefmt. diff --git a/changelog.d/5-internal/cannon-log-register-remote-presence-failure b/changelog.d/5-internal/cannon-log-register-remote-presence-failure deleted file mode 100644 index 9ebbac9c3c3..00000000000 --- a/changelog.d/5-internal/cannon-log-register-remote-presence-failure +++ /dev/null @@ -1,4 +0,0 @@ -Cannon now logs an error when registering a client's remote presence with -Gundeck fails, so operators can tell this apart from an actual -websocket/network issue (e.g. `PongTimeout` caused by Gundeck losing its Redis -connection). diff --git a/changelog.d/5-internal/configurable-alpine-images-cannon-cassandra-migrations b/changelog.d/5-internal/configurable-alpine-images-cannon-cassandra-migrations deleted file mode 100644 index 92119fa9353..00000000000 --- a/changelog.d/5-internal/configurable-alpine-images-cannon-cassandra-migrations +++ /dev/null @@ -1,19 +0,0 @@ -The alpine base images used by the `cannon-configurator` initContainer (`wire-server` -chart) and the `job-done` container (`cassandra-migrations` chart) are no longer -hard-coded. They can now be set via `cannon.configuratorImage.{repository,tag,pullPolicy}` -in the `wire-server` chart and `jobDoneImage.{repository,tag}` in the -`cassandra-migrations` chart. - -The default was bumped from `alpine:3.21.3` to `alpine:3.24.1`, since alpine 3.21 -reaches end-of-support on 2026-11-01. The local integration stack's `init_vhosts` -container also moved from `alpine/curl:3.14` (an Alpine 3.14 image last published -in 2021) to `alpine/curl:8.21.0`. - -Operators who mirror images into a private registry should make sure the new -`alpine:3.24.1` tag is cached, or override `repository` to point at their mirror. - -The chart release tooling (`hack/bin/set-wire-server-image-version.sh`, -`hack/bin/set-chart-image-version.sh`) now anchors its version stamping to -`repository: quay.io/wire/` lines instead of matching `tag:` by indentation, so -third-party image tags in `values.yaml` are no longer overwritten with the -wire-server release version. diff --git a/changelog.d/5-internal/email-templates-v1.0.155 b/changelog.d/5-internal/email-templates-v1.0.155 deleted file mode 100644 index bc4a3f536c5..00000000000 --- a/changelog.d/5-internal/email-templates-v1.0.155 +++ /dev/null @@ -1 +0,0 @@ -Updated email templates to v1.0.155 \ No newline at end of file diff --git a/changelog.d/5-internal/fix-sbomnix b/changelog.d/5-internal/fix-sbomnix deleted file mode 100644 index b9c6f74c9e2..00000000000 --- a/changelog.d/5-internal/fix-sbomnix +++ /dev/null @@ -1,2 +0,0 @@ -Fix `#sbom` Nix env / sbomnix usage by upgrading to latest stable version of -the latter. The issue was introduced by upgrading `nixpkgs` to 26.05. diff --git a/changelog.d/5-internal/remove-bulk-get-rich-info b/changelog.d/5-internal/remove-bulk-get-rich-info deleted file mode 100644 index bf2b726f6c2..00000000000 --- a/changelog.d/5-internal/remove-bulk-get-rich-info +++ /dev/null @@ -1 +0,0 @@ -brig: Remove /i/users/rich-info \ No newline at end of file diff --git a/changelog.d/5-internal/remove-sftd-disco b/changelog.d/5-internal/remove-sftd-disco deleted file mode 100644 index 0cbcfdde66a..00000000000 --- a/changelog.d/5-internal/remove-sftd-disco +++ /dev/null @@ -1 +0,0 @@ -remove sftd_disco (now lives in wireapp/wire-avs-service) diff --git a/changelog.d/5-internal/wire-image-mirror-for-integration-tests b/changelog.d/5-internal/wire-image-mirror-for-integration-tests deleted file mode 100644 index b6108c22388..00000000000 --- a/changelog.d/5-internal/wire-image-mirror-for-integration-tests +++ /dev/null @@ -1,5 +0,0 @@ -Use wire image mirror for integration tests as `public.ecr.aws/bitnami/` is no -longer available -(https://aws.amazon.com/blogs/containers/bitnami-image-removal-from-ecr-public/). -Docker Hub has strict rate-limiting. So, in lieu of better options, we now use -our own image cache at `quay.io/wire/mirror-images`. diff --git a/changelog.d/5-internal/wpb-27553-meeting-tzid-notnull b/changelog.d/5-internal/wpb-27553-meeting-tzid-notnull deleted file mode 100644 index 3aff5543e3b..00000000000 --- a/changelog.d/5-internal/wpb-27553-meeting-tzid-notnull +++ /dev/null @@ -1,3 +0,0 @@ -Internal: `meetings.tzid` is now `NOT NULL`, backfilled to `Europe/Berlin`. -`end_time` is the source of truth (there are no `duration`/`duration_original` -columns). (WPB-27553) diff --git a/changelog.d/5-internal/zhost-domain b/changelog.d/5-internal/zhost-domain deleted file mode 100644 index ad3c95fe1fe..00000000000 --- a/changelog.d/5-internal/zhost-domain +++ /dev/null @@ -1,4 +0,0 @@ -The `Z-Host` header has been treated as domain, but used as `Text`. -De-serializing and thus using it as `Domain` increases type-safety and ensures -domain related semantics; e.g. case insensitivity in equality checks. -This solves a `FUTUREWORK` remark which was around for quite some time. diff --git a/changelog.d/6-federation/WPB-27060 b/changelog.d/6-federation/WPB-27060 deleted file mode 100644 index d266b42b8c2..00000000000 --- a/changelog.d/6-federation/WPB-27060 +++ /dev/null @@ -1 +0,0 @@ -`deeplink.json` now contains a new optional field `supportEmail` that may be used by clients.