diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b77843975a..2d595d71b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,69 @@ +# [2024-12-11] (Chart Release 5.8.0) + +## Release notes + + +* [RabbitMQ events] Notifications are now also sent via RabbitMQ. Therefore RabbitMQ is now a required dependency for Cannon and Gundeck. Cassandra is now a required dependency for Cannon and Background-Worker. Both of them need access to the Gundeck keyspace. These are breaking changes for Charts. (#4272, #4358, #4340) + +* If brig's server values config has the field `emailSMS.team`, the correct value for the personal user to team invitation URL must be set under `emailSMS.team.tExistingUserInvitationUrl`. Otherwise the URL will point to a path under the account pages and therefore a value for `externalUrls.accountPages` is required. (#4341) + + +## API changes + + +* The endpoint `POST /teams/:tid/invitations` gained a new optional field `allow_existing`, which controls whether an existing personal user should be invited to the team (#4336) + + +## Features + + +* Welcome email for new team owner. (#4333) + +* Added inviter's email to `GET /teams/invitation/info` endpoint. (#4332) + + +## Bug fixes and other updates + + +* Updated `nginz` config for personal user to team flow (#4334) + +* Freeze API version 7, create new dev version 8. Also update checklist. (#4356, #4356) + +* Fixed config for personal user to team invitation URL template. (#4341) + +* Fixed search index after personal user creates team (#4362) + + +## Documentation + + +* Add a few more swagger descriptions and examples. (#4323) + + +## Internal changes + + +* `charts/wire-server-enterprise` is a Helm chart to run the `wire-server-enterprise` + service. This service can only be deployed with an image pull secret (the + registry is not open to public.) (#4359) + +* [Polysemy] Move email update and remove operations to effects (#4316, #4316) + +* Log uncaught IO exceptions in cargohold (#4352) + +* Updated email templates to v1.0.124 (#4328) + +* charts/galley: Make missing mls keys a templating error. Update MLS docs. (#4369) + +* [RabbitMQ events] New endpoint `GET /events` for consuming events is added (in API V8). + + - When a client misses notifications because it was offline for too long, it needs to know this information so it can do a full synchronisation. This appears as the first notification in `GET /events` endpoint whenever the system detects this happening. The next acknowledgement of the message makes this notification not appear anymore until the next notification is missed. (#4272) + - New internal endpoint `POST /i/users/:uid/clients/:cid/consumable-notifications` is added (#4272) + - Connection pooling in cannon (#4348) + - Add consumers to the draining step on Cannon, in case of termination. (#4342) + - List queues more efficiently. (#4351) + + # [2024-11-04] (Chart Release 5.7.0) ## Bug fixes and other updates diff --git a/Makefile b/Makefile index ea55403761f..3814a47779d 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ DOCKER_TAG ?= $(USER) # default helm chart version must be 0.0.42 for local development (because 42 is the answer to the universe and everything) HELM_SEMVER ?= 0.0.42 # The list of helm charts needed on internal kubernetes testing environments -CHARTS_INTEGRATION := wire-server databases-ephemeral redis-cluster rabbitmq fake-aws ingress-nginx-controller nginx-ingress-controller nginx-ingress-services fluent-bit kibana restund k8ssandra-test-cluster +CHARTS_INTEGRATION := wire-server databases-ephemeral redis-cluster rabbitmq fake-aws ingress-nginx-controller nginx-ingress-controller nginx-ingress-services fluent-bit kibana restund k8ssandra-test-cluster wire-server-enterprise # The list of helm charts to publish on S3 # FUTUREWORK: after we "inline local subcharts", # (e.g. move charts/brig to charts/wire-server/brig) @@ -18,7 +18,7 @@ fake-aws fake-aws-s3 fake-aws-sqs aws-ingress fluent-bit kibana backoffice \ calling-test demo-smtp elasticsearch-curator elasticsearch-external \ elasticsearch-ephemeral minio-external cassandra-external \ nginx-ingress-controller ingress-nginx-controller nginx-ingress-services reaper restund \ -k8ssandra-test-cluster ldap-scim-bridge +k8ssandra-test-cluster ldap-scim-bridge wire-server-enterprise KIND_CLUSTER_NAME := wire-server HELM_PARALLELISM ?= 1 # 1 for sequential tests; 6 for all-parallel tests @@ -51,7 +51,12 @@ install: init .PHONY: rabbit-clean rabbit-clean: - rabbitmqadmin -f pretty_json list queues vhost name messages | jq -r '.[] | "rabbitmqadmin delete queue name=\(.name) --vhost=\(.vhost)"' | bash + rabbitmqadmin -f pretty_json list queues vhost name \ + | jq -r '.[] | "rabbitmqadmin delete queue name=\(.name) --vhost=\(.vhost)"' \ + | bash + rabbitmqadmin -f pretty_json list exchanges name vhost \ + | jq -r '.[] |select(.name | startswith("amq") | not) | select (.name != "") | "rabbitmqadmin delete exchange name=\(.name) --vhost=\(.vhost)"' \ + | bash # Clean .PHONY: full-clean @@ -134,7 +139,7 @@ crm: c db-migrate # Usage: TEST_INCLUDE=test1,test2 make devtest .PHONY: devtest devtest: - ghcid --command 'cabal repl integration' --test='Testlib.Run.mainI []' + ghcid --command 'cabal repl lib:integration' --test='Testlib.Run.mainI []' .PHONY: sanitize-pr sanitize-pr: @@ -370,15 +375,6 @@ db-migrate: c libzauth: $(MAKE) -C libs/libzauth install -################################# -# Useful when using Haskell IDE Engine -# https://github.com/haskell/haskell-ide-engine -# -# Run this again after changes to libraries or dependencies. -.PHONY: hie.yaml -hie.yaml: - echo -e 'cradle:\n cabal: {}' > hie.yaml - ##################################### # Today we pretend to be CI and run integration tests on kubernetes # (see also docs/developer/processes.md) diff --git a/cassandra-schema.cql b/cassandra-schema.cql index 28fad0acf4a..bc454c6fa36 100644 --- a/cassandra-schema.cql +++ b/cassandra-schema.cql @@ -1729,6 +1729,26 @@ CREATE TABLE gundeck_test.meta ( AND read_repair_chance = 0.0 AND speculative_retry = '99PERCENTILE'; +CREATE TABLE gundeck_test.missed_notifications ( + user_id uuid, + client_id text, + PRIMARY KEY (user_id, client_id) +) WITH CLUSTERING ORDER BY (client_id ASC) + AND bloom_filter_fp_chance = 0.01 + AND caching = {'keys': 'ALL', 'rows_per_partition': 'NONE'} + AND comment = '' + AND compaction = {'class': 'org.apache.cassandra.db.compaction.SizeTieredCompactionStrategy', 'max_threshold': '32', 'min_threshold': '4'} + AND compression = {'chunk_length_in_kb': '64', 'class': 'org.apache.cassandra.io.compress.LZ4Compressor'} + AND crc_check_chance = 1.0 + AND dclocal_read_repair_chance = 0.1 + AND default_time_to_live = 0 + 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_chance = 0.0 + AND speculative_retry = '99PERCENTILE'; + CREATE TABLE gundeck_test.push ( ptoken text, app text, diff --git a/changelog.d/0-release-notes/4349 b/changelog.d/0-release-notes/4349 new file mode 100644 index 00000000000..4be18d4b8b2 --- /dev/null +++ b/changelog.d/0-release-notes/4349 @@ -0,0 +1,3 @@ +* POST /scim/auth-token request body allows you to choose an IdP UUID to associate with. If none is given, do not associate. + + **WARNING:** the new behavior differs from the old one when first creating a unique SAML IdP and then the SCIM token: before this release, this request would associate the two, now it doesn't. (#4349) diff --git a/changelog.d/2-features/4349 b/changelog.d/2-features/4349 new file mode 100644 index 00000000000..ee589265e3f --- /dev/null +++ b/changelog.d/2-features/4349 @@ -0,0 +1 @@ +* You can now create both multiple SCIM peers and multiple SAML IdPs, and freely associate them with each other (team management app implementation pending). (#4349) diff --git a/charts/README.md b/charts/README.md new file mode 100644 index 00000000000..f6a2e676218 --- /dev/null +++ b/charts/README.md @@ -0,0 +1,6 @@ +# wire-server-enterprise + +This service contains the non-open parts of wire-server. + +The image registry is password protected. The credential can e.g. be provided by +defining `secrets.configJson` with the value provided by Wire. diff --git a/charts/background-worker/templates/configmap.yaml b/charts/background-worker/templates/configmap.yaml index 8840a43764e..25ac5238bd1 100644 --- a/charts/background-worker/templates/configmap.yaml +++ b/charts/background-worker/templates/configmap.yaml @@ -21,6 +21,12 @@ data: host: federator port: 8080 + cassandra: + endpoint: + host: {{ .cassandra.host }} + port: 9042 + keyspace: gundeck + {{- with .rabbitmq }} rabbitmq: host: {{ .host }} diff --git a/charts/background-worker/templates/deployment.yaml b/charts/background-worker/templates/deployment.yaml index bbc0b6f71f4..3ee60e4b89a 100644 --- a/charts/background-worker/templates/deployment.yaml +++ b/charts/background-worker/templates/deployment.yaml @@ -36,6 +36,11 @@ spec: - name: "background-worker-secrets" secret: secretName: "background-worker" + {{- if eq (include "useCassandraTLS" .Values.config) "true" }} + - name: "background-worker-cassandra" + secret: + secretName: {{ (include "tlsSecretRef" .Values.config | fromYaml).name }} + {{- end }} {{- if .Values.config.rabbitmq.tlsCaSecretRef }} - name: "rabbitmq-ca" secret: @@ -52,6 +57,10 @@ spec: volumeMounts: - name: "background-worker-config" mountPath: "/etc/wire/background-worker/conf" + {{- if eq (include "useCassandraTLS" .Values.config) "true" }} + - name: "background-worker-cassandra" + mountPath: "/etc/wire/background-worker/cassandra" + {{- end }} {{- if .Values.config.rabbitmq.tlsCaSecretRef }} - name: "rabbitmq-ca" mountPath: "/etc/wire/background-worker/rabbitmq-ca/" diff --git a/charts/background-worker/values.yaml b/charts/background-worker/values.yaml index 8b79f6af6be..1f8c113732e 100644 --- a/charts/background-worker/values.yaml +++ b/charts/background-worker/values.yaml @@ -29,6 +29,8 @@ config: # tlsCaSecretRef: # name: # key: + cassandra: + host: aws-cassandra backendNotificationPusher: pushBackoffMinWait: 10000 # in microseconds, so 10ms diff --git a/charts/brig/templates/configmap.yaml b/charts/brig/templates/configmap.yaml index 7c732c7b590..3ee161a8c02 100644 --- a/charts/brig/templates/configmap.yaml +++ b/charts/brig/templates/configmap.yaml @@ -186,14 +186,13 @@ data: {{- else }} {{- if .externalUrls.teamSettings }} tInvitationUrl: {{ .externalUrls.teamSettings }}/join/?team-code=${code} - tExistingUserInvitationUrl: {{ .externalUrls.teamSettings }}/accept-invitation/?team-code=${code} {{- else }} tInvitationUrl: {{ .externalUrls.nginz }}/register?team=${team}&team_code=${code} - tExistingUserInvitationUrl: {{ .externalUrls.nginz }}/accept-invitation/?team-code=${code} {{- end }} tActivationUrl: {{ .externalUrls.nginz }}/register?team=${team}&team_code=${code} tCreatorWelcomeUrl: {{ .externalUrls.teamCreatorWelcome }} tMemberWelcomeUrl: {{ .externalUrls.teamMemberWelcome }} + tExistingUserInvitationUrl: {{ .externalUrls.accountPages }}/accept-invitation/?team-code=${code} {{- end }} zauth: diff --git a/charts/cannon/templates/configmap.yaml b/charts/cannon/templates/configmap.yaml index 6537fc0172a..bf085d9179f 100644 --- a/charts/cannon/templates/configmap.yaml +++ b/charts/cannon/templates/configmap.yaml @@ -1,25 +1,46 @@ apiVersion: v1 data: + {{- with .Values }} cannon.yaml: | - logFormat: {{ .Values.config.logFormat }} - logLevel: {{ .Values.config.logLevel }} - logNetStrings: {{ .Values.config.logNetStrings }} + logFormat: {{ .config.logFormat }} + logLevel: {{ .config.logLevel }} + logNetStrings: {{ .config.logNetStrings }} cannon: host: 0.0.0.0 - port: {{ .Values.service.externalPort }} + port: {{ .service.externalPort }} externalHostFile: /etc/wire/cannon/externalHost/host.txt gundeck: host: gundeck port: 8080 + cassandra: + endpoint: + host: {{ .config.cassandra.host }} + port: 9042 + keyspace: gundeck + + {{- with .config.rabbitmq }} + rabbitmq: + host: {{ .host }} + port: {{ .port }} + vHost: {{ .vHost }} + enableTls: {{ .enableTls }} + insecureSkipVerifyTls: {{ .insecureSkipVerifyTls }} + {{- if .tlsCaSecretRef }} + caCert: /etc/wire/cannon/rabbitmq-ca/{{ .tlsCaSecretRef.key }} + {{- end }} + {{- end }} + drainOpts: - gracePeriodSeconds: {{ .Values.config.drainOpts.gracePeriodSeconds }} - millisecondsBetweenBatches: {{ .Values.config.drainOpts.millisecondsBetweenBatches }} - minBatchSize: {{ .Values.config.drainOpts.minBatchSize }} + gracePeriodSeconds: {{ .config.drainOpts.gracePeriodSeconds }} + millisecondsBetweenBatches: {{ .config.drainOpts.millisecondsBetweenBatches }} + minBatchSize: {{ .config.drainOpts.minBatchSize }} + + disabledAPIVersions: {{ toJson .config.disabledAPIVersions }} + {{- end }} - disabledAPIVersions: {{ toJson .Values.config.disabledAPIVersions }} kind: ConfigMap metadata: diff --git a/charts/cannon/templates/secret.yaml b/charts/cannon/templates/secret.yaml new file mode 100644 index 00000000000..1b6f9ebd94e --- /dev/null +++ b/charts/cannon/templates/secret.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Secret +metadata: + name: cannon + labels: + app: cannon + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + rabbitmqUsername: {{ .Values.secrets.rabbitmq.username | b64enc | quote }} + rabbitmqPassword: {{ .Values.secrets.rabbitmq.password | b64enc | quote }} + diff --git a/charts/cannon/templates/statefulset.yaml b/charts/cannon/templates/statefulset.yaml index 2931ce01b90..44566c78801 100644 --- a/charts/cannon/templates/statefulset.yaml +++ b/charts/cannon/templates/statefulset.yaml @@ -92,6 +92,17 @@ spec: {{ toYaml .Values.resources | indent 12 }} {{- end }} - name: cannon + env: + - name: RABBITMQ_USERNAME + valueFrom: + secretKeyRef: + name: cannon + key: rabbitmqUsername + - name: RABBITMQ_PASSWORD + valueFrom: + secretKeyRef: + name: cannon + key: rabbitmqPassword image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" {{- if eq (include "includeSecurityContext" .) "true" }} securityContext: @@ -102,6 +113,10 @@ spec: mountPath: /etc/wire/cannon/externalHost - name: cannon-config mountPath: /etc/wire/cannon/conf + {{- if .Values.config.rabbitmq.tlsCaSecretRef }} + - name: rabbitmq-ca + mountPath: "/etc/wire/cannon/rabbitmq-ca/" + {{- end }} ports: - name: http containerPort: {{ .Values.service.internalPort }} @@ -155,3 +170,8 @@ spec: secret: secretName: {{ .Values.service.nginz.tls.secretName }} {{- end }} + {{- if .Values.config.rabbitmq.tlsCaSecretRef }} + - name: rabbitmq-ca + secret: + secretName: {{ .Values.config.rabbitmq.tlsCaSecretRef.name }} + {{- end }} diff --git a/charts/cannon/values.yaml b/charts/cannon/values.yaml index 350ffebc50a..93041914a22 100644 --- a/charts/cannon/values.yaml +++ b/charts/cannon/values.yaml @@ -11,6 +11,35 @@ config: logLevel: Info logFormat: StructuredJSON logNetStrings: false + rabbitmq: + host: rabbitmq + port: 5672 + vHost: / + enableTls: false + insecureSkipVerifyTls: false + cassandra: + host: aws-cassandra + # To enable TLS provide a CA: + # tlsCa: + # + # Or refer to an existing secret (containing the CA): + # tlsCaSecretRef: + # name: + # key: + + redis: + host: redis-ephemeral-master + port: 6379 + connectionMode: "master" # master | cluster + enableTls: false + insecureSkipVerifyTls: false + # To configure custom TLS CA, please provide one of these: + # tlsCa: + # + # Or refer to an existing secret (containing the CA): + # tlsCaSecretRef: + # name: + # key: # See also the section 'Controlling the speed of websocket draining during # cannon pod replacement' in docs/how-to/install/configuration-options.rst diff --git a/charts/galley/templates/secret.yaml b/charts/galley/templates/secret.yaml index 84995f51bc5..7224b67c59e 100644 --- a/charts/galley/templates/secret.yaml +++ b/charts/galley/templates/secret.yaml @@ -10,19 +10,11 @@ metadata: type: Opaque data: {{- if .Values.secrets.mlsPrivateKeys }} - {{- if .Values.secrets.mlsPrivateKeys.removal.ed25519 }} removal_ed25519.pem: {{ .Values.secrets.mlsPrivateKeys.removal.ed25519 | b64enc | quote }} - {{- end -}} - {{- if .Values.secrets.mlsPrivateKeys.removal.ecdsa_secp256r1_sha256 }} removal_ecdsa_secp256r1_sha256.pem: {{ .Values.secrets.mlsPrivateKeys.removal.ecdsa_secp256r1_sha256 | b64enc | quote }} - {{- end -}} - {{- if .Values.secrets.mlsPrivateKeys.removal.ecdsa_secp384r1_sha384 }} removal_ecdsa_secp384r1_sha384.pem: {{ .Values.secrets.mlsPrivateKeys.removal.ecdsa_secp384r1_sha384 | b64enc | quote }} - {{- end -}} - {{- if .Values.secrets.mlsPrivateKeys.removal.ecdsa_secp521r1_sha512 }} removal_ecdsa_secp521r1_sha512.pem: {{ .Values.secrets.mlsPrivateKeys.removal.ecdsa_secp521r1_sha512 | b64enc | quote }} {{- end -}} - {{- end -}} {{- if $.Values.config.enableFederation }} rabbitmqUsername: {{ .Values.secrets.rabbitmq.username | b64enc | quote }} diff --git a/charts/gundeck/templates/configmap.yaml b/charts/gundeck/templates/configmap.yaml index cf7c37e1a7c..d067c6508a0 100644 --- a/charts/gundeck/templates/configmap.yaml +++ b/charts/gundeck/templates/configmap.yaml @@ -29,6 +29,18 @@ data: tlsCa: /etc/wire/gundeck/cassandra/{{- (include "tlsSecretRef" . | fromYaml).key }} {{- end }} + {{- with .rabbitmq }} + rabbitmq: + host: {{ .host }} + port: {{ .port }} + vHost: {{ .vHost }} + enableTls: {{ .enableTls }} + insecureSkipVerifyTls: {{ .insecureSkipVerifyTls }} + {{- if .tlsCaSecretRef }} + caCert: /etc/wire/gundeck/rabbitmq-ca/{{ .tlsCaSecretRef.key }} + {{- end }} + {{- end }} + redis: host: {{ .redis.host }} port: {{ .redis.port }} diff --git a/charts/gundeck/templates/deployment.yaml b/charts/gundeck/templates/deployment.yaml index ee67ba1ba43..a6a3c320a3e 100644 --- a/charts/gundeck/templates/deployment.yaml +++ b/charts/gundeck/templates/deployment.yaml @@ -39,6 +39,11 @@ spec: - name: "gundeck-config" configMap: name: "gundeck" + {{- if .Values.config.rabbitmq.tlsCaSecretRef }} + - name: "rabbitmq-ca" + secret: + secretName: {{ .Values.config.rabbitmq.tlsCaSecretRef.name }} + {{- end }} {{- if eq (include "useCassandraTLS" .Values.config) "true" }} - name: "gundeck-cassandra" secret: @@ -77,7 +82,21 @@ spec: - name: "additional-redis-ca" mountPath: "/etc/wire/gundeck/additional-redis-ca/" {{- end }} + {{- if .Values.config.rabbitmq.tlsCaSecretRef }} + - name: "rabbitmq-ca" + mountPath: "/etc/wire/gundeck/rabbitmq-ca/" + {{- end }} env: + - name: RABBITMQ_USERNAME + valueFrom: + secretKeyRef: + name: gundeck + key: rabbitmqUsername + - name: RABBITMQ_PASSWORD + valueFrom: + secretKeyRef: + name: gundeck + key: rabbitmqPassword {{- if hasKey .Values.secrets "awsKeyId" }} - name: AWS_ACCESS_KEY_ID valueFrom: diff --git a/charts/gundeck/templates/secret.yaml b/charts/gundeck/templates/secret.yaml index eae9c4ab33d..67c61afc220 100644 --- a/charts/gundeck/templates/secret.yaml +++ b/charts/gundeck/templates/secret.yaml @@ -11,6 +11,8 @@ metadata: type: Opaque data: {{- with .Values.secrets }} + rabbitmqUsername: {{ .rabbitmq.username | b64enc | quote }} + rabbitmqPassword: {{ .rabbitmq.password | b64enc | quote }} {{- if hasKey . "awsKeyId" }} awsKeyId: {{ .awsKeyId | b64enc | quote }} {{- end }} diff --git a/charts/gundeck/templates/tests/gundeck-integration.yaml b/charts/gundeck/templates/tests/gundeck-integration.yaml index 9aa7b56347d..a2aa75e52cd 100644 --- a/charts/gundeck/templates/tests/gundeck-integration.yaml +++ b/charts/gundeck/templates/tests/gundeck-integration.yaml @@ -23,6 +23,11 @@ spec: secret: secretName: {{ include "redisTlsSecretName" .Values.config }} {{- end }} + {{- if .Values.config.rabbitmq.tlsCaSecretRef }} + - name: "rabbitmq-ca" + secret: + secretName: {{ .Values.config.rabbitmq.tlsCaSecretRef.name }} + {{- end }} containers: - name: integration # TODO: When deployed to staging (or real AWS env), _all_ tests should be run @@ -72,6 +77,10 @@ spec: - name: "redis-ca" mountPath: "/etc/wire/gundeck/redis-ca/" {{- end }} + {{- if .Values.config.rabbitmq.tlsCaSecretRef }} + - name: "rabbitmq-ca" + mountPath: "/etc/wire/gundeck/rabbitmq-ca/" + {{- end }} env: # these dummy values are necessary for Amazonka's "Discover" - name: AWS_ACCESS_KEY_ID @@ -82,6 +91,11 @@ spec: value: "eu-west-1" - name: TEST_XML value: /tmp/result.xml + # RabbitMQ needs dummy credentials for the tests to run + - name: RABBITMQ_USERNAME + value: "guest" + - name: RABBITMQ_PASSWORD + value: "guest" {{- if hasKey .Values.secrets "redisUsername" }} - name: REDIS_USERNAME valueFrom: diff --git a/charts/gundeck/values.yaml b/charts/gundeck/values.yaml index 9749dd94be8..e5500c02db5 100644 --- a/charts/gundeck/values.yaml +++ b/charts/gundeck/values.yaml @@ -18,6 +18,13 @@ config: logLevel: Info logFormat: StructuredJSON logNetStrings: false + rabbitmq: + host: rabbitmq + port: 5672 + adminPort: 15672 + vHost: / + enableTls: false + insecureSkipVerifyTls: false cassandra: host: aws-cassandra # To enable TLS provide a CA: diff --git a/charts/integration/templates/integration-integration.yaml b/charts/integration/templates/integration-integration.yaml index 3fe4284dc5b..dd351986e7b 100644 --- a/charts/integration/templates/integration-integration.yaml +++ b/charts/integration/templates/integration-integration.yaml @@ -261,6 +261,12 @@ spec: - name: rabbitmq-ca mountPath: /etc/wire/background-worker/rabbitmq-ca + - name: rabbitmq-ca + mountPath: /etc/wire/gundeck/rabbitmq-ca + + - name: rabbitmq-ca + mountPath: /etc/wire/cannon/rabbitmq-ca + {{- if eq (include "useCassandraTLS" .Values.config) "true" }} - name: "integration-cassandra" mountPath: "/certs" diff --git a/charts/nginz/values.yaml b/charts/nginz/values.yaml index b853e1b2cde..8560dab1ac4 100644 --- a/charts/nginz/values.yaml +++ b/charts/nginz/values.yaml @@ -468,6 +468,9 @@ nginx_conf: - path: /oauth/applications envs: - all + - path: /upgrade-personal-to-team$ + envs: + - all galley: - path: /conversations/code-check disable_zauth: true @@ -557,6 +560,9 @@ nginx_conf: disable_zauth: true basic_auth: true versioned: false + - path: /teams/invitations/accept$ + envs: + - all - path: /custom-backend/by-domain/([^/]*)$ disable_zauth: true envs: diff --git a/charts/wire-server-enterprise/Chart.yaml b/charts/wire-server-enterprise/Chart.yaml new file mode 100644 index 00000000000..23e202346bc --- /dev/null +++ b/charts/wire-server-enterprise/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: additional enterprise features for wire-server +name: wire-server-enterprise +version: 0.0.42 diff --git a/charts/wire-server-enterprise/templates/configmap.yaml b/charts/wire-server-enterprise/templates/configmap.yaml new file mode 100644 index 00000000000..bf901564405 --- /dev/null +++ b/charts/wire-server-enterprise/templates/configmap.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: wire-server-enterprise + labels: + app: wire-server-enterprise + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +data: + {{- with .Values.config }} + wire-server-enterprise.yaml: | + logNetStrings: {{ .logNetStrings }} + logFormat: {{ .logFormat }} + logLevel: {{ .logLevel }} + + wireServerEnterprise: + host: 0.0.0.0 + port: 8080 + {{- end }} diff --git a/charts/wire-server-enterprise/templates/deployment.yaml b/charts/wire-server-enterprise/templates/deployment.yaml new file mode 100644 index 00000000000..e14b6389691 --- /dev/null +++ b/charts/wire-server-enterprise/templates/deployment.yaml @@ -0,0 +1,56 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: wire-server-enterprise + labels: + app: wire-server-enterprise + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: {{ .Values.replicaCount }} + selector: + matchLabels: + app: wire-server-enterprise + template: + metadata: + labels: + app: wire-server-enterprise + release: {{ .Release.Name }} + annotations: + # 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: + imagePullSecrets: + - name: wire-server-enterprise-readonly-pull-secret + volumes: + - name: "wire-server-enterprise-config" + configMap: + name: "wire-server-enterprise" + containers: + - name: wire-server-enterprise + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ default "" .Values.imagePullPolicy | quote }} + ports: + - containerPort: {{ .Values.service.internalPort }} + livenessProbe: + httpGet: + scheme: HTTP + path: /i/status + port: {{ .Values.service.internalPort }} + readinessProbe: + httpGet: + scheme: HTTP + path: /i/status + port: {{ .Values.service.internalPort }} + resources: +{{ toYaml .Values.resources | indent 12 }} + volumeMounts: + - name: "wire-server-enterprise-config" + mountPath: "/etc/wire/wire-server-enterprise/conf" + automountServiceAccountToken: false diff --git a/charts/wire-server-enterprise/templates/image-pull-secret.yaml b/charts/wire-server-enterprise/templates/image-pull-secret.yaml new file mode 100644 index 00000000000..c6a27fc34f2 --- /dev/null +++ b/charts/wire-server-enterprise/templates/image-pull-secret.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Secret +metadata: + name: wire-server-enterprise-readonly-pull-secret + labels: + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: kubernetes.io/dockerconfigjson +data: + {{/* for_helm_linting is necessary only since the 'with' block below does not throw an error upon an empty .Values.secrets */}} + for_helm_linting: {{ required "No .secrets found in configuration." .Values.secrets | quote | b64enc | quote }} + + {{- with .Values.secrets }} + .dockerconfigjson: {{ .configJson }} + {{- end }} diff --git a/charts/wire-server-enterprise/templates/service.yaml b/charts/wire-server-enterprise/templates/service.yaml new file mode 100644 index 00000000000..a985fc74b8f --- /dev/null +++ b/charts/wire-server-enterprise/templates/service.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Service +metadata: + name: wire-server-enterprise + labels: + app: wire-server-enterprise + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} + annotations: + {{- if ge (.Capabilities.KubeVersion.Minor|int) 26 }} + service.kubernetes.io/topology-mode: Auto + {{- else }} + service.kubernetes.io/topology-aware-hints: auto + {{- end }} +spec: + type: ClusterIP + ports: + - name: http + port: {{ .Values.service.externalPort }} + targetPort: {{ .Values.service.internalPort }} + selector: + app: wire-server-enterprise + release: {{ .Release.Name }} diff --git a/charts/wire-server-enterprise/templates/servicemonitor.yaml b/charts/wire-server-enterprise/templates/servicemonitor.yaml new file mode 100644 index 00000000000..dab2a8d4044 --- /dev/null +++ b/charts/wire-server-enterprise/templates/servicemonitor.yaml @@ -0,0 +1,19 @@ +{{- if .Values.metrics.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: wire-server-enterprise + labels: + app: wire-server-enterprise + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + endpoints: + - port: http + path: /i/metrics + selector: + matchLabels: + app: wire-server-enterprise + release: {{ .Release.Name }} +{{- end }} diff --git a/charts/wire-server-enterprise/values.yaml b/charts/wire-server-enterprise/values.yaml new file mode 100644 index 00000000000..7fba58e31ca --- /dev/null +++ b/charts/wire-server-enterprise/values.yaml @@ -0,0 +1,25 @@ +replicaCount: 1 + +image: + repository: quay.io/wire/wire-server-enterprise + tag: do-not-use + +resources: + requests: + memory: "200Mi" + cpu: "100m" + limits: + memory: "512Mi" + +service: + internalPort: 8080 + externalPort: 8080 + +metrics: + serviceMonitor: + enabled: false + +config: + logLevel: Info + logFormat: StructuredJSON + logNetStrings: false diff --git a/deploy/dockerephemeral/docker-compose.yaml b/deploy/dockerephemeral/docker-compose.yaml index 13061660d8c..73c7324ae99 100644 --- a/deploy/dockerephemeral/docker-compose.yaml +++ b/deploy/dockerephemeral/docker-compose.yaml @@ -271,6 +271,7 @@ services: ports: - '127.0.0.1:5671:5671' - '127.0.0.1:15671:15671' + - '127.0.0.1:15672:15672' volumes: - ./rabbitmq-config/rabbitmq.conf:/etc/rabbitmq/conf.d/20-wire.conf - ./rabbitmq-config/certificates:/etc/rabbitmq/certificates diff --git a/deploy/dockerephemeral/federation-v0/spar.yaml b/deploy/dockerephemeral/federation-v0/spar.yaml index 4a7024d0c68..f8dc9135787 100644 --- a/deploy/dockerephemeral/federation-v0/spar.yaml +++ b/deploy/dockerephemeral/federation-v0/spar.yaml @@ -36,7 +36,7 @@ cassandra: maxttlAuthreq: 5 # seconds. don't set this too large, it is also the run time of one TTL test. maxttlAuthresp: 7200 # seconds. do not set this to 1h or less, as that is what the mock idp wants. -maxScimTokens: 2 # Token limit {#RefScimToken} +maxScimTokens: 8 # Token limit {#RefScimToken} richInfoLimit: 5000 # should be in sync with Brig logNetStrings: False # log using netstrings encoding (see http://cr.yp.to/proto/netstrings.txt) diff --git a/deploy/dockerephemeral/federation-v1/spar.yaml b/deploy/dockerephemeral/federation-v1/spar.yaml index e111292bc03..516305fed1b 100644 --- a/deploy/dockerephemeral/federation-v1/spar.yaml +++ b/deploy/dockerephemeral/federation-v1/spar.yaml @@ -38,7 +38,7 @@ disabledAPIVersions: [] maxttlAuthreq: 5 # seconds. don't set this too large, it is also the run time of one TTL test. maxttlAuthresp: 7200 # seconds. do not set this to 1h or less, as that is what the mock idp wants. -maxScimTokens: 2 # Token limit {#RefScimToken} +maxScimTokens: 8 # Token limit {#RefScimToken} richInfoLimit: 5000 # should be in sync with Brig logNetStrings: False # log using netstrings encoding (see http://cr.yp.to/proto/netstrings.txt) diff --git a/docs/src/developer/developer/api-versioning.md b/docs/src/developer/developer/api-versioning.md index 1ef2f7bc6fd..f7998422726 100644 --- a/docs/src/developer/developer/api-versioning.md +++ b/docs/src/developer/developer/api-versioning.md @@ -115,8 +115,7 @@ the version. In these example we assume that version `V6` should be finalized an - In the same `Version` module update the `developmentVersions` value to list only the new version. - In `services/brig/src/Brig/API/Public.hs` - - update `versionedSwaggerDocsAPI` so that the finalized version points to the pregenerated swagger - - and `internalEndpointsSwaggerDocsAPI` so that the finalized version `V6`, the new version `V7`, as well as the unversioned path point to the swagger of the internal API, and the previous latest stable version V5 points to an empty swagger. + - update `versionedSwaggerDocsAPI` so that the finalized version points to the pregenerated swagger, and the dynamically generated swagger spits out swagger for the new `V7`. - Set the version for `gDefaultAPIVersion` in `integration/test/Testlib/Env.hs` to 7. - Consider updating the `backendApiVersion` value in Stern, which is unit-tested by checking if it is listed as supported in the response to `GET diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index 74565d0dd21..41748ebc3ba 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -801,6 +801,27 @@ This setting is required to be present for all the services (brig, cannon, cargo The default value (provided under `charts//values.yaml`) is `[ development ]` and disables the development versions. To enable all versions including the development versions set the value to be empty: `[]`. +### Team invitation URL for personal users + +To configure the team invitation URL for personal users that is sent vai email, `emailSMS.team.tExistingUserInvitationUrl` should be set to the desired URL, e.g.: + +```yaml +brig: + config + emailSMS: + team: + tExistingUserInvitationUrl: '{{ .Values.accountUrl }}/accept-invitation/?team-code=${code}' +``` + +In some environments the `team` config section does not exist. In this case brig's configmap constructs the URL from the account pages URL which then must be set under `externalUrls.accountPages` e.g. as follows: + +```yaml +brig: + config: + externalUrls: + accountPages: https://account.wire.com +``` + ## Settings in cargohold AWS S3 (or an alternative provider / service) is used to upload and download diff --git a/docs/src/understand/mls.md b/docs/src/understand/mls.md index 99e26c2f2dd..3df365b5a86 100644 --- a/docs/src/understand/mls.md +++ b/docs/src/understand/mls.md @@ -9,7 +9,14 @@ enables the server to remove clients from MLS groups, e.g. when users leave conversations or delete their clients. The removal key is configured at path -`galley.secrets.mlsPrivateKeys.removal.ed25519` in the wire-server helm chart. +`galley.secrets.mlsPrivateKeys.removal` in the wire-server helm chart. +You need to provide a variant for each supported ciphersuite: +- `ed25519` +- `ecdsa_secp256r1_sha256` +- `ecdsa_secp384r1_sha384` +- `ecdsa_secp521r1_sha512` + + For example: ```yaml @@ -20,25 +27,32 @@ galley: removal: ed25519: | -----BEGIN PRIVATE KEY----- - MC4CAQA....Z709c - -----END PRIVATE KEY----- + ... + ecdsa_secp256r1_sha256: | + -----BEGIN PRIVATE KEY----- + ... + ecdsa_secp384r1_sha384: | + -----BEGIN PRIVATE KEY----- + ... + ecdsa_secp521r1_sha512: | + -----BEGIN PRIVATE KEY----- + ... ``` -The key is a private ED25519 key in PEM format. It can be created by openssl -with this command: +These private keys can be created with with these commands: ```sh -openssl req -nodes -newkey ed25519 -keyout ed25519.pem -out /dev/null -subj / +openssl genpkey -algorithm ed25519 +openssl genpkey -algorithm ec -pkeyopt ec_paramgen_curve:P-256 +openssl genpkey -algorithm ec -pkeyopt ec_paramgen_curve:P-384 +openssl genpkey -algorithm ec -pkeyopt ec_paramgen_curve:P-521 ``` -This will create a `ed25519.pem`. Use the contents of this file as the -configuration value. - This is a sensitive configuration value. Consider using Helm/Helmfile's support for managing secrets instead of putting this value in plaintext in a `values.yaml` file. -Next, MLS needs to be explictly enabled in brig. This can be configured at +In addition to removal keys, MLS needs to be explictly enabled in brig. This can be configured at `brig.config.optSettings.setEnableMLS`, for example: ```yaml diff --git a/docs/src/understand/single-sign-on/understand/main.md b/docs/src/understand/single-sign-on/understand/main.md index adabf76b74a..3df00226f30 100644 --- a/docs/src/understand/single-sign-on/understand/main.md +++ b/docs/src/understand/single-sign-on/understand/main.md @@ -441,6 +441,34 @@ curl -X DELETE --header "Authorization: Bearer $BEARER" \ $WIRE_BACKEND/scim/auth-tokens?id=$SCIM_TOKEN_ID ``` +#### Associating SCIM tokens with SAML IdPs for authentication + +You can create both multiple SAML IdPs and multiple SCIM peers. If +both numbers are non-zero, there is the question of how the +provisioned users should be authenticated: should they be invited via +password, and other users authenticated *and* provisioned by SAML? Or +should they be provisioned so they can login with their SAML +credentials? + +The request body in the [scim token creation +request](https://staging-nginz-https.zinfra.io/v7/api/swagger-ui/#/default/auth-tokens-create) +has an optional parameter `idp` that can contain an IdP's UUID. This +allows you to associate a SCIM token with a SAML IdP at creation of +the scim token. + +If you already have a SCIM token and want to associate it with a SAML +IdP, delete the SCIM token and create a new one. The user accounts +provisioned with that token will remain unaffected. + +If you do not provide a SAML IdP when creating it, the behavior +differs based on the version you use: + +**V6 and below:** If there is a unique IdP registered with your team, +associate implicitly. Otherwise, do not associate. + +**V7 and above:** Never associate unless explicitly told to do so. + + #### Using a SCIM token to Create Read Update and Delete (CRUD) users Now that you have your SCIM token, you can use it to talk to the SCIM API to manipulate (create, read, update, delete) users, either individually or in bulk. diff --git a/hack/helm_vars/wire-federation-v0/values.yaml.gotmpl b/hack/helm_vars/wire-federation-v0/values.yaml.gotmpl index c012a3b19f1..a38cbbdbf05 100644 --- a/hack/helm_vars/wire-federation-v0/values.yaml.gotmpl +++ b/hack/helm_vars/wire-federation-v0/values.yaml.gotmpl @@ -270,7 +270,7 @@ spar: ssoUri: http://spar:8080/sso maxttlAuthreq: 5 maxttlAuthresp: 7200 - maxScimTokens: 2 + maxScimTokens: 8 contacts: - type: ContactSupport company: Example Company diff --git a/hack/helm_vars/wire-server/values.yaml.gotmpl b/hack/helm_vars/wire-server/values.yaml.gotmpl index e7f72583f39..e10c635c70b 100644 --- a/hack/helm_vars/wire-server/values.yaml.gotmpl +++ b/hack/helm_vars/wire-server/values.yaml.gotmpl @@ -57,6 +57,7 @@ brig: nginz: https://kube-staging-nginz-https.zinfra.io teamCreatorWelcome: https://teams.wire.com/login teamMemberWelcome: https://wire.com/download + accountPages: https://account.wire.com cassandra: host: {{ .Values.cassandraHost }} replicaCount: 1 @@ -80,7 +81,7 @@ brig: enableTls: true insecureSkipVerifyTls: false tlsCaSecretRef: - name: rabbitmq-certificate + name: "rabbitmq-certificate" key: "ca.crt" authSettings: userTokenTimeout: 120 @@ -134,7 +135,7 @@ brig: setOAuthEnabled: true setOAuthRefreshTokenExpirationTimeSecs: 14515200 # 24 weeks setOAuthMaxActiveRefreshTokens: 10 - # These values are insecure, against anyone getting hold of the hash, + # These values are insecure, against anyone getting hold of the hash, # but its not a concern for the integration tests. setPasswordHashingOptions: algorithm: argon2id @@ -205,7 +206,23 @@ cannon: memory: 512Mi drainTimeout: 0 config: + cassandra: + host: {{ .Values.cassandraHost }} + replicaCount: 1 disabledAPIVersions: [] + rabbitmq: + port: 5671 + adminPort: 15671 + enableTls: true + insecureSkipVerifyTls: false + tlsCaSecretRef: + name: "rabbitmq-certificate" + key: "ca.crt" + secrets: + rabbitmq: + username: {{ .Values.rabbitmqUsername }} + password: {{ .Values.rabbitmqPassword }} + cargohold: replicaCount: 1 imagePullPolicy: {{ .Values.imagePullPolicy }} @@ -252,7 +269,7 @@ galley: enableTls: true insecureSkipVerifyTls: false tlsCaSecretRef: - name: rabbitmq-certificate + name: "rabbitmq-certificate" key: "ca.crt" enableFederation: true # keep in sync with brig.config.enableFederation, cargohold.config.enableFederation and tags.federator! settings: @@ -265,7 +282,7 @@ galley: federationDomain: integration.example.com disabledAPIVersions: [] - # These values are insecure, against anyone getting hold of the hash, + # These values are insecure, against anyone getting hold of the hash, # but its not a concern for the integration tests. passwordHashingOptions: algorithm: argon2id @@ -373,6 +390,14 @@ gundeck: name: "cassandra-jks-keystore" key: "ca.crt" {{- end }} + rabbitmq: + port: 5671 + adminPort: 15671 + enableTls: true + insecureSkipVerifyTls: false + tlsCaSecretRef: + name: "rabbitmq-certificate" + key: "ca.crt" redis: host: redis-ephemeral-master connectionMode: master @@ -395,6 +420,9 @@ gundeck: awsKeyId: dummykey awsSecretKey: dummysecret redisPassword: very-secure-redis-master-password + rabbitmq: + username: {{ .Values.rabbitmqUsername }} + password: {{ .Values.rabbitmqPassword }} tests: {{- if .Values.uploadXml }} config: @@ -467,7 +495,7 @@ spar: ssoUri: http://spar:8080/sso maxttlAuthreq: 5 maxttlAuthresp: 7200 - maxScimTokens: 2 + maxScimTokens: 8 contacts: - type: ContactSupport company: Example Company @@ -518,13 +546,21 @@ background-worker: pushBackoffMinWait: 1000 # 1ms pushBackoffMaxWait: 500000 # 0.5s remotesRefreshInterval: 1000000 # 1s + cassandra: + host: {{ .Values.cassandraHost }} + replicaCount: 1 + {{- if .Values.useK8ssandraSSL.enabled }} + tlsCaSecretRef: + name: "cassandra-jks-keystore" + key: "ca.crt" + {{- end }} rabbitmq: port: 5671 adminPort: 15671 enableTls: true insecureSkipVerifyTls: false tlsCaSecretRef: - name: rabbitmq-certificate + name: "rabbitmq-certificate" key: "ca.crt" secrets: rabbitmq: diff --git a/hack/helmfile.yaml b/hack/helmfile.yaml index 3581c373a78..447b980777e 100644 --- a/hack/helmfile.yaml +++ b/hack/helmfile.yaml @@ -1,5 +1,5 @@ --- -# This helfile is used for the setup of two ephemeral backends on kubernetes +# This helmfile is used for the setup of two ephemeral backends on kubernetes # during integration testing (including federation integration tests spanning # over 2 backends) # This helmfile is used via the './hack/bin/integration-setup-federation.sh' via diff --git a/integration/default.nix b/integration/default.nix index 37d66c8daf5..76522a8d9c7 100644 --- a/integration/default.nix +++ b/integration/default.nix @@ -22,6 +22,8 @@ , cookie , cql , cql-io +, criterion +, cryptobox-haskell , crypton , crypton-x509 , cryptostore @@ -84,6 +86,7 @@ , wire-message-proto-lens , wreq , xml +, xml-conduit , yaml }: mkDerivation { @@ -119,6 +122,8 @@ mkDerivation { cookie cql cql-io + criterion + cryptobox-haskell crypton crypton-x509 cryptostore @@ -178,6 +183,7 @@ mkDerivation { wire-message-proto-lens wreq xml + xml-conduit yaml ]; license = lib.licenses.agpl3Only; diff --git a/integration/integration.cabal b/integration/integration.cabal index a3989f28e76..3c15b930344 100644 --- a/integration/integration.cabal +++ b/integration/integration.cabal @@ -121,6 +121,7 @@ library Test.Demo Test.EJPD Test.Errors + Test.Events Test.ExternalPartner Test.FeatureFlags Test.FeatureFlags.AppLock @@ -169,6 +170,7 @@ library Test.Search Test.Services Test.Spar + Test.Spar.STM Test.Swagger Test.Teams Test.TeamSettings @@ -217,6 +219,8 @@ library , cookie , cql , cql-io + , criterion + , cryptobox-haskell , crypton , crypton-x509 , cryptostore @@ -276,4 +280,5 @@ library , wire-message-proto-lens , wreq , xml + , xml-conduit , yaml diff --git a/integration/test/API/Brig.hs b/integration/test/API/Brig.hs index d084bdf542d..b788d3c6f74 100644 --- a/integration/test/API/Brig.hs +++ b/integration/test/API/Brig.hs @@ -265,6 +265,12 @@ searchTeam user q = do req <- baseRequest user Brig Versioned $ joinHttpPath ["teams", tid, "search"] submit "GET" (req & addQueryParams [("q", q)]) +searchTeamAll :: (HasCallStack, MakesValue user) => user -> App Response +searchTeamAll user = do + tid <- user %. "team" & asString + req <- baseRequest user Brig Versioned $ joinHttpPath ["teams", tid, "search"] + submit "GET" (req & addQueryParams [("q", ""), ("size", "100"), ("sortby", "created_at"), ("sortorder", "desc")]) + getAPIVersion :: (HasCallStack, MakesValue domain) => domain -> App Response getAPIVersion domain = do req <- baseRequest domain Brig Unversioned $ "/api-version" @@ -818,6 +824,12 @@ listInvitations user tid = do req <- baseRequest user Brig Versioned $ joinHttpPath ["teams", tid, "invitations"] submit "GET" req +-- | https://staging-nginz-https.zinfra.io/v7/api/swagger-ui/#/default/get-team-invitation-info +getInvitationByCode :: (HasCallStack, MakesValue user) => user -> String -> App Response +getInvitationByCode user code = do + req <- baseRequest user Brig Versioned $ joinHttpPath ["teams", "invitations", "info"] + submit "GET" (req & addQueryParams [("code", code)]) + passwordReset :: (HasCallStack, MakesValue domain) => domain -> String -> App Response passwordReset domain email = do req <- baseRequest domain Brig Versioned "password-reset" diff --git a/integration/test/API/Galley.hs b/integration/test/API/Galley.hs index d1c4066ae70..902df3862c6 100644 --- a/integration/test/API/Galley.hs +++ b/integration/test/API/Galley.hs @@ -120,17 +120,15 @@ deleteTeamMember tid owner mem = do putConversationProtocol :: ( HasCallStack, MakesValue user, - MakesValue qcnv, MakesValue protocol ) => user -> - qcnv -> + ConvId -> protocol -> App Response -putConversationProtocol user qcnv protocol = do - (domain, cnv) <- objQid qcnv +putConversationProtocol user convId protocol = do p <- asString protocol - req <- baseRequest user Galley Versioned (joinHttpPath ["conversations", domain, cnv, "protocol"]) + req <- baseRequest user Galley Versioned (joinHttpPath ["conversations", convId.domain, convId.id_, "protocol"]) submit "PUT" (req & addJSONObject ["protocol" .= p]) getConversation :: @@ -148,21 +146,19 @@ getConversation user qcnv = do getSubConversation :: ( HasCallStack, - MakesValue user, - MakesValue conv + MakesValue user ) => user -> - conv -> + ConvId -> String -> App Response getSubConversation user conv sub = do - (cnvDomain, cnvId) <- objQid conv req <- baseRequest user Galley Versioned $ joinHttpPath [ "conversations", - cnvDomain, - cnvId, + conv.domain, + conv.id_, "subconversations", sub ] @@ -184,16 +180,15 @@ deleteSubConversation user sub = do submit "DELETE" $ req & addJSONObject ["group_id" .= groupId, "epoch" .= epoch] leaveSubConversation :: - (HasCallStack, MakesValue user, MakesValue sub) => + (HasCallStack, MakesValue user) => user -> - sub -> + ConvId -> App Response -leaveSubConversation user sub = do - (conv, Just subId) <- objSubConv sub - (domain, convId) <- objQid conv +leaveSubConversation user convId = do + let Just subId = convId.subconvId req <- baseRequest user Galley Versioned - $ joinHttpPath ["conversations", domain, convId, "subconversations", subId, "self"] + $ joinHttpPath ["conversations", convId.domain, convId.id_, "subconversations", subId, "self"] submit "DELETE" req getSelfConversation :: (HasCallStack, MakesValue user) => user -> App Response @@ -278,16 +273,14 @@ mkProteusRecipients dom userClients msg = do & #text .~ fromString msg getGroupInfo :: - (HasCallStack, MakesValue user, MakesValue conv) => + (HasCallStack, MakesValue user) => user -> - conv -> + ConvId -> App Response getGroupInfo user conv = do - (qcnv, mSub) <- objSubConv conv - (convDomain, convId) <- objQid qcnv - let path = joinHttpPath $ case mSub of - Nothing -> ["conversations", convDomain, convId, "groupinfo"] - Just sub -> ["conversations", convDomain, convId, "subconversations", sub, "groupinfo"] + let path = joinHttpPath $ case conv.subconvId of + Nothing -> ["conversations", conv.domain, conv.id_, "groupinfo"] + Just sub -> ["conversations", conv.domain, conv.id_, "subconversations", sub, "groupinfo"] req <- baseRequest user Galley Versioned path submit "GET" req @@ -323,7 +316,7 @@ deleteTeamConv :: App Response deleteTeamConv team conv user = do teamId <- objId team - convId <- objId conv + convId <- objId $ objQidObject conv req <- baseRequest user Galley Versioned (joinHttpPath ["teams", teamId, "conversations", convId]) submit "DELETE" req @@ -745,3 +738,12 @@ getTeamMembersCsv :: (HasCallStack, MakesValue user) => user -> String -> App Re getTeamMembersCsv user tid = do req <- baseRequest user Galley Versioned (joinHttpPath ["teams", tid, "members", "csv"]) submit "GET" req + +-- | https://staging-nginz-https.zinfra.io/v6/api/swagger-ui/#/default/post_conversations__cnv_domain___cnv__typing +sendTypingStatus :: (HasCallStack, MakesValue user, MakesValue conv) => user -> conv -> String -> App Response +sendTypingStatus user conv status = do + convDomain <- objDomain conv + convId <- objId conv + req <- baseRequest user Galley Versioned (joinHttpPath ["conversations", convDomain, convId, "typing"]) + submit "POST" + $ addJSONObject ["status" .= status] req diff --git a/integration/test/API/GalleyInternal.hs b/integration/test/API/GalleyInternal.hs index de6f5c21c47..bb8a471ce36 100644 --- a/integration/test/API/GalleyInternal.hs +++ b/integration/test/API/GalleyInternal.hs @@ -114,13 +114,6 @@ patchTeamFeatureConfig domain team featureName payload = do req <- baseRequest domain Galley Unversioned $ joinHttpPath ["i", "teams", tid, "features", fn] submit "PATCH" $ req & addJSON p --- https://staging-nginz-https.zinfra.io/api-internal/swagger-ui/galley/#/galley/post_i_features_multi_teams_searchVisibilityInbound -getFeatureStatusMulti :: (HasCallStack, MakesValue domain, MakesValue featureName) => domain -> featureName -> [String] -> App Response -getFeatureStatusMulti domain featureName tids = do - fn <- asString featureName - req <- baseRequest domain Galley Unversioned $ joinHttpPath ["i", "features-multi-teams", fn] - submit "POST" $ req & addJSONObject ["teams" .= tids] - patchTeamFeature :: (HasCallStack, MakesValue domain, MakesValue team) => domain -> team -> String -> Value -> App Response patchTeamFeature domain team featureName payload = do tid <- asString team diff --git a/integration/test/API/Nginz.hs b/integration/test/API/Nginz.hs index ac248fd544f..cee0b05f4b6 100644 --- a/integration/test/API/Nginz.hs +++ b/integration/test/API/Nginz.hs @@ -90,3 +90,8 @@ buildUploadAssetRequestBody isPublic retention body mimeType = do "retention" .= mbRetention ] HTTP.RequestBodyLBS <$> buildMultipartBody header' body mimeType + +upgradePersonalToTeam :: (HasCallStack, MakesValue user) => user -> String -> String -> App Response +upgradePersonalToTeam user token name = do + req <- baseRequest user Brig Versioned $ joinHttpPath ["upgrade-personal-to-team"] + submit "POST" $ req & addJSONObject ["name" .= name, "icon" .= "default"] & addHeader "Authorization" ("Bearer " <> token) diff --git a/integration/test/API/Spar.hs b/integration/test/API/Spar.hs index c925c7cc5d7..36cb2becd7d 100644 --- a/integration/test/API/Spar.hs +++ b/integration/test/API/Spar.hs @@ -1,10 +1,15 @@ module API.Spar where import API.Common (defPassword) +import qualified Data.ByteString.Base64.Lazy as EL +import Data.String.Conversions (cs) import Data.String.Conversions.Monomorphic (fromLT) import GHC.Stack +import Network.HTTP.Client.MultipartFormData import qualified SAML2.WebSSO as SAML +import qualified SAML2.WebSSO.Test.MockResponse as SAML import Testlib.Prelude +import qualified Text.XML as XML -- | https://staging-nginz-https.zinfra.io/v6/api/swagger-ui/#/default/get_scim_auth_tokens getScimTokens :: (HasCallStack, MakesValue caller) => caller -> App Response @@ -12,17 +17,39 @@ getScimTokens caller = do req <- baseRequest caller Spar Versioned "/scim/auth-tokens" submit "GET" req --- | https://staging-nginz-https.zinfra.io/v5/api/swagger-ui/#/default/post_scim_auth_tokens -createScimToken :: (HasCallStack, MakesValue caller) => caller -> App Response -createScimToken caller = do - req <- baseRequest caller Spar Versioned "/scim/auth-tokens" - submit "POST" $ req & addJSONObject ["password" .= defPassword, "description" .= "integration test"] +data CreateScimToken = CreateScimToken + { password :: String, + description :: Maybe String, + name :: Maybe String, + idp :: Maybe String + } + deriving stock (Generic, Show) + +instance Default CreateScimToken where + def = CreateScimToken defPassword (Just "integration test") Nothing Nothing + +instance ToJSON CreateScimToken where + toJSON = genericToJSON $ defaultOptions {fieldLabelModifier = camelTo2 '_'} -- | https://staging-nginz-https.zinfra.io/v5/api/swagger-ui/#/default/post_scim_auth_tokens -createScimTokenWithName :: (HasCallStack, MakesValue caller) => caller -> String -> App Response -createScimTokenWithName caller name = do +createScimTokenV6 :: (HasCallStack, MakesValue caller) => caller -> CreateScimToken -> App Response +createScimTokenV6 caller payload = do + req <- baseRequest caller Spar (ExplicitVersion 6) "/scim/auth-tokens" + j <- make payload + submit "POST" $ req & addJSON j + +createScimToken :: (HasCallStack, MakesValue caller) => caller -> CreateScimToken -> App Response +createScimToken caller payload = do req <- baseRequest caller Spar Versioned "/scim/auth-tokens" - submit "POST" $ req & addJSONObject ["password" .= defPassword, "description" .= "integration test", "name" .= name] + j <- make payload + submit "POST" $ req & addJSON j + +-- | https://staging-nginz-https.zinfra.io/v7/api/swagger-ui/#/default/auth-tokens-delete +deleteScimToken :: (HasCallStack, MakesValue caller) => caller -> String -> App Response +deleteScimToken caller token = do + req <- baseRequest caller Spar Versioned $ joinHttpPath ["scim", "auth-tokens"] + submit "DELETE" $ req + & addQueryParams [("id", token)] putScimTokenName :: (HasCallStack, MakesValue caller) => caller -> String -> String -> App Response putScimTokenName caller token name = do @@ -35,6 +62,12 @@ createScimUser domain token scimUser = do body <- make scimUser submit "POST" $ req & addJSON body . addHeader "Authorization" ("Bearer " <> token) +deleteScimUser :: (HasCallStack, MakesValue domain) => domain -> String -> String -> App Response +deleteScimUser domain token uid = do + req <- baseRequest domain Spar Versioned $ joinHttpPath ["scim", "v2", "Users", uid] + submit "DELETE" $ req + & addHeader "Authorization" ("Bearer " <> token) + findUsersByExternalId :: (HasCallStack, MakesValue domain) => domain -> String -> String -> App Response findUsersByExternalId domain scimToken externalId = do req <- baseRequest domain Spar Versioned "/scim/v2/Users" @@ -60,8 +93,33 @@ updateScimUser domain scimToken userId scimUser = do createIdp :: (HasCallStack, MakesValue user) => user -> SAML.IdPMetadata -> App Response createIdp user metadata = do - req <- baseRequest user Spar Unversioned "/identity-providers" + req <- baseRequest user Spar Versioned "/identity-providers" submit "POST" $ req & addQueryParams [("api_version", "v2")] & addXML (fromLT $ SAML.encode metadata) & addHeader "Content-Type" "application/xml" + +-- | https://staging-nginz-https.zinfra.io/v7/api/swagger-ui/#/default/idp-get-all +getIdps :: (HasCallStack, MakesValue user) => user -> App Response +getIdps user = do + req <- baseRequest user Spar Versioned "/identity-providers" + submit "GET" req + +-- | https://staging-nginz-https.zinfra.io/v7/api/swagger-ui/#/default/sso-team-metadata +getSPMetadata :: (HasCallStack, MakesValue domain) => domain -> String -> App Response +getSPMetadata domain tid = do + req <- baseRequest domain Spar Versioned $ joinHttpPath ["sso", "metadata", tid] + submit "GET" req + +-- | https://staging-nginz-https.zinfra.io/v7/api/swagger-ui/#/default/auth-req +initiateSamlLogin :: (HasCallStack, MakesValue domain) => domain -> String -> App Response +initiateSamlLogin domain idpId = do + req <- baseRequest domain Spar Versioned $ joinHttpPath ["sso", "initiate-login", idpId] + submit "GET" req + +-- | https://staging-nginz-https.zinfra.io/v7/api/swagger-ui/#/default/auth-resp +finalizeSamlLogin :: (HasCallStack, MakesValue domain) => domain -> String -> SAML.SignedAuthnResponse -> App Response +finalizeSamlLogin domain tid (SAML.SignedAuthnResponse authnresp) = do + baseRequest domain Spar Versioned (joinHttpPath ["sso", "finalize-login", tid]) + >>= formDataBody [partLBS (cs "SAMLResponse") . EL.encode . XML.renderLBS XML.def $ authnresp] + >>= submit "POST" diff --git a/integration/test/MLS/Util.hs b/integration/test/MLS/Util.hs index f5e753cf88b..e70fa74d259 100644 --- a/integration/test/MLS/Util.hs +++ b/integration/test/MLS/Util.hs @@ -3,6 +3,7 @@ module MLS.Util where import API.Brig +import API.BrigCommon import API.Galley import Control.Concurrent.Async hiding (link) import Control.Monad @@ -11,7 +12,6 @@ import Control.Monad.Codensity import Control.Monad.Cont import Control.Monad.Reader import Control.Monad.Trans.Maybe -import qualified Data.Aeson as A import qualified Data.Aeson as Aeson import qualified Data.ByteString as BS import qualified Data.ByteString.Base64 as Base64 @@ -33,6 +33,7 @@ import System.Directory import System.Exit import System.FilePath import System.IO hiding (print, putStrLn) +import System.IO.Error (isAlreadyExistsError) import System.IO.Temp import System.Posix.Files import System.Process @@ -40,6 +41,7 @@ import Testlib.Assertions import Testlib.HTTP import Testlib.JSON import Testlib.Prelude +import Testlib.Printing mkClientIdentity :: (MakesValue u, MakesValue c) => u -> c -> App ClientIdentity mkClientIdentity u c = do @@ -52,18 +54,12 @@ cid2Str cid = cid.user <> ":" <> cid.client <> "@" <> cid.domain data MessagePackage = MessagePackage { sender :: ClientIdentity, + convId :: ConvId, message :: ByteString, welcome :: Maybe ByteString, groupInfo :: Maybe ByteString } -getConv :: App Value -getConv = do - mls <- getMLSState - case mls.convId of - Nothing -> assertFailure "Uninitialised test conversation" - Just convId -> pure convId - toRandomFile :: ByteString -> App FilePath toRandomFile bs = do p <- randomFileName @@ -75,16 +71,15 @@ randomFileName = do bd <- getBaseDir (bd ) . UUID.toString <$> liftIO UUIDV4.nextRandom -mlscli :: (HasCallStack) => ClientIdentity -> [String] -> Maybe ByteString -> App ByteString -mlscli cid args mbstdin = do +mlscli :: (HasCallStack) => Maybe ConvId -> Ciphersuite -> ClientIdentity -> [String] -> Maybe ByteString -> App ByteString +mlscli mConvId cs cid args mbstdin = do groupOut <- randomFileName let substOut = argSubst "" groupOut - cs <- (.ciphersuite) <$> getMLSState let scheme = csSignatureScheme cs gs <- getClientGroupState cid - substIn <- case gs.group of + substIn <- case flip Map.lookup gs.groups =<< mConvId of Nothing -> pure id Just groupData -> do fn <- toRandomFile groupData @@ -92,7 +87,11 @@ mlscli cid args mbstdin = do store <- case Map.lookup scheme gs.keystore of Nothing -> do bd <- getBaseDir - liftIO $ createDirectory (bd cid2Str cid) + liftIO (createDirectory (bd cid2Str cid)) + `catch` \e -> + if (isAlreadyExistsError e) + then assertFailure "client directory for mls state already exists" + else throwM e -- initialise new keystore path <- randomFileName @@ -109,11 +108,15 @@ mlscli cid args mbstdin = do out <- runCli store args' mbstdin setGroup <- do groupOutWritten <- liftIO $ doesFileExist groupOut - if groupOutWritten - then do + case (groupOutWritten, mConvId) of + (True, Just convId) -> do groupData <- liftIO (BS.readFile groupOut) - pure $ \x -> x {group = Just groupData} - else pure id + pure $ \x -> x {groups = Map.insert convId groupData x.groups} + (True, Nothing) -> do + print $ colored red "mls-test-cli: Group was written but no convId was provided, this probably indicates something is going to go wrong in this test." + print =<< liftIO (prettierCallStack callStack) + pure id + _ -> pure id setStore <- do storeData <- liftIO (BS.readFile store) pure $ \x -> x {keystore = Map.insert scheme storeData x.keystore} @@ -137,27 +140,28 @@ argSubst :: String -> String -> String -> String argSubst from to_ s = if s == from then to_ else s -createWireClient :: (MakesValue u, HasCallStack) => u -> App ClientIdentity -createWireClient u = do - addClient u def +createWireClient :: (MakesValue u, HasCallStack) => u -> AddClient -> App ClientIdentity +createWireClient u clientArgs = do + addClient u clientArgs >>= getJSON 201 >>= mkClientIdentity u data InitMLSClient = InitMLSClient - {credType :: CredentialType} + { credType :: CredentialType, + clientArgs :: AddClient + } instance Default InitMLSClient where - def = InitMLSClient {credType = BasicCredentialType} + def = InitMLSClient {credType = BasicCredentialType, clientArgs = def} -- | Create new mls client and register with backend. -createMLSClient :: (MakesValue u, HasCallStack) => InitMLSClient -> u -> App ClientIdentity -createMLSClient opts u = do - cid <- createWireClient u +createMLSClient :: (MakesValue u, HasCallStack) => Ciphersuite -> InitMLSClient -> u -> App ClientIdentity +createMLSClient ciphersuite opts u = do + cid <- createWireClient u opts.clientArgs setClientGroupState cid def {credType = opts.credType} -- set public key - pkey <- mlscli cid ["public-key"] Nothing - ciphersuite <- (.ciphersuite) <$> getMLSState + pkey <- mlscli Nothing ciphersuite cid ["public-key"] Nothing bindResponse ( updateClient cid @@ -170,9 +174,9 @@ createMLSClient opts u = do pure cid -- | create and upload to backend -uploadNewKeyPackage :: (HasCallStack) => ClientIdentity -> App String -uploadNewKeyPackage cid = do - (kp, ref) <- generateKeyPackage cid +uploadNewKeyPackage :: (HasCallStack) => Ciphersuite -> ClientIdentity -> App String +uploadNewKeyPackage suite cid = do + (kp, ref) <- generateKeyPackage cid suite -- upload key package bindResponse (uploadKeyPackages cid [kp]) $ \resp -> @@ -180,94 +184,100 @@ uploadNewKeyPackage cid = do pure ref -generateKeyPackage :: (HasCallStack) => ClientIdentity -> App (ByteString, String) -generateKeyPackage cid = do - suite <- (.ciphersuite) <$> getMLSState - kp <- mlscli cid ["key-package", "create", "--ciphersuite", suite.code] Nothing - ref <- B8.unpack . Base64.encode <$> mlscli cid ["key-package", "ref", "-"] (Just kp) +generateKeyPackage :: (HasCallStack) => ClientIdentity -> Ciphersuite -> App (ByteString, String) +generateKeyPackage cid suite = do + kp <- mlscli Nothing suite cid ["key-package", "create", "--ciphersuite", suite.code] Nothing + ref <- B8.unpack . Base64.encode <$> mlscli Nothing suite cid ["key-package", "ref", "-"] (Just kp) fp <- keyPackageFile cid ref liftIO $ BS.writeFile fp kp pure (kp, ref) -- | Create conversation and corresponding group. -createNewGroup :: (HasCallStack) => ClientIdentity -> App (String, Value) -createNewGroup cid = do +createNewGroup :: (HasCallStack) => Ciphersuite -> ClientIdentity -> App ConvId +createNewGroup cs cid = do conv <- postConversation cid defMLS >>= getJSON 201 - groupId <- conv %. "group_id" & asString - convId <- conv %. "qualified_id" - createGroup cid conv - pure (groupId, convId) + convId <- objConvId conv + createGroup cs cid convId + pure convId -- | Retrieve self conversation and create the corresponding group. -createSelfGroup :: (HasCallStack) => ClientIdentity -> App (String, Value) -createSelfGroup cid = do +createSelfGroup :: (HasCallStack) => Ciphersuite -> ClientIdentity -> App (String, Value) +createSelfGroup cs cid = do conv <- getSelfConversation cid >>= getJSON 200 + convId <- objConvId conv groupId <- conv %. "group_id" & asString - createGroup cid conv + createGroup cs cid convId pure (groupId, conv) -createGroup :: (MakesValue conv) => ClientIdentity -> conv -> App () -createGroup cid conv = do - mls <- getMLSState - case mls.groupId of - Just _ -> assertFailure "only one group can be created" - Nothing -> pure () - resetGroup cid conv - -createSubConv :: (HasCallStack) => ClientIdentity -> String -> App () -createSubConv cid subId = do - mls <- getMLSState - sub <- getSubConversation cid mls.convId subId >>= getJSON 200 - resetGroup cid sub - void $ createPendingProposalCommit cid >>= sendAndConsumeCommitBundle - -createOne2OneSubConv :: (HasCallStack, MakesValue keys) => ClientIdentity -> String -> keys -> App () -createOne2OneSubConv cid subId keys = do - mls <- getMLSState - sub <- getSubConversation cid mls.convId subId >>= getJSON 200 - resetOne2OneGroupGeneric cid sub keys - void $ createPendingProposalCommit cid >>= sendAndConsumeCommitBundle - -resetGroup :: (HasCallStack, MakesValue conv) => ClientIdentity -> conv -> App () -resetGroup cid conv = do - convId <- objSubConvObject conv - groupId <- conv %. "group_id" & asString +createGroup :: Ciphersuite -> ClientIdentity -> ConvId -> App () +createGroup cs cid convId = do + let Just groupId = convId.groupId modifyMLSState $ \s -> - s - { groupId = Just groupId, - convId = Just convId, - members = Set.singleton cid, - epoch = 0, - newMembers = mempty - } + let mlsConv = + MLSConv + { members = Set.singleton cid, + newMembers = mempty, + groupId, + convId = convId, + epoch = 0, + ciphersuite = cs + } + in s {convs = Map.insert convId mlsConv s.convs} keys <- getMLSPublicKeys cid.qualifiedUserId >>= getJSON 200 - resetClientGroup cid groupId keys - -resetOne2OneGroup :: (HasCallStack, MakesValue one2OneConv) => ClientIdentity -> one2OneConv -> App () -resetOne2OneGroup cid one2OneConv = - resetOne2OneGroupGeneric cid (one2OneConv %. "conversation") (one2OneConv %. "public_keys") + resetClientGroup cs cid groupId convId keys + +createSubConv :: (HasCallStack) => Ciphersuite -> ConvId -> ClientIdentity -> String -> App () +createSubConv cs convId cid subId = do + sub <- getSubConversation cid convId subId >>= getJSON 200 + subConvId <- objConvId sub + createGroup cs cid subConvId + void $ createPendingProposalCommit subConvId cid >>= sendAndConsumeCommitBundle + +createOne2OneSubConv :: (HasCallStack, MakesValue keys) => Ciphersuite -> ConvId -> ClientIdentity -> String -> keys -> App () +createOne2OneSubConv cs convId cid subId keys = do + sub <- getSubConversation cid convId subId >>= getJSON 200 + subConvId <- objConvId sub + resetOne2OneGroupGeneric cs cid sub keys + void $ createPendingProposalCommit subConvId cid >>= sendAndConsumeCommitBundle + +resetOne2OneGroup :: (HasCallStack, MakesValue one2OneConv) => Ciphersuite -> ClientIdentity -> one2OneConv -> App () +resetOne2OneGroup cs cid one2OneConv = + resetOne2OneGroupGeneric cs cid (one2OneConv %. "conversation") (one2OneConv %. "public_keys") -- | Useful when keys are to be taken from main conv and the conv here is the subconv -resetOne2OneGroupGeneric :: (HasCallStack, MakesValue conv, MakesValue keys) => ClientIdentity -> conv -> keys -> App () -resetOne2OneGroupGeneric cid conv keys = do - convId <- objSubConvObject conv +resetOne2OneGroupGeneric :: (HasCallStack, MakesValue conv, MakesValue keys) => Ciphersuite -> ClientIdentity -> conv -> keys -> App () +resetOne2OneGroupGeneric cs cid conv keys = do + convId <- objConvId conv groupId <- conv %. "group_id" & asString modifyMLSState $ \s -> - s - { groupId = Just groupId, - convId = Just convId, - members = Set.singleton cid, - epoch = 0, - newMembers = mempty - } - resetClientGroup cid groupId keys - -resetClientGroup :: (HasCallStack, MakesValue keys) => ClientIdentity -> String -> keys -> App () -resetClientGroup cid gid keys = do - mls <- getMLSState - removalKey <- asByteString $ keys %. ("removal." <> csSignatureScheme mls.ciphersuite) + let newMLSConv = + MLSConv + { members = Set.singleton cid, + newMembers = mempty, + groupId = groupId, + convId = convId, + epoch = 0, + ciphersuite = cs + } + resetConv old new = + old + { groupId = new.groupId, + convId = new.convId, + members = new.members, + newMembers = new.newMembers, + epoch = new.epoch + } + in s {convs = Map.insertWith resetConv convId newMLSConv s.convs} + + resetClientGroup cs cid groupId convId keys + +resetClientGroup :: (HasCallStack, MakesValue keys) => Ciphersuite -> ClientIdentity -> String -> ConvId -> keys -> App () +resetClientGroup cs cid gid convId keys = do + removalKey <- asByteString $ keys %. ("removal." <> csSignatureScheme cs) void $ mlscli + (Just convId) + cs cid [ "group", "create", @@ -276,7 +286,7 @@ resetClientGroup cid gid keys = do "--group-out", "", "--ciphersuite", - mls.ciphersuite.code, + cs.code, gid ] (Just removalKey) @@ -310,13 +320,13 @@ unbundleKeyPackages bundle = do -- Note that this alters the state of the group immediately. If we want to test -- a scenario where the commit is rejected by the backend, we can restore the -- group to the previous state by using an older version of the group file. -createAddCommit :: (HasCallStack) => ClientIdentity -> [Value] -> App MessagePackage -createAddCommit cid users = do - mls <- getMLSState +createAddCommit :: (HasCallStack) => ClientIdentity -> ConvId -> [Value] -> App MessagePackage +createAddCommit cid convId users = do + conv <- getMLSConv convId kps <- fmap concat . for users $ \user -> do - bundle <- claimKeyPackages mls.ciphersuite cid user >>= getJSON 200 + bundle <- claimKeyPackages conv.ciphersuite cid user >>= getJSON 200 unbundleKeyPackages bundle - createAddCommitWithKeyPackages cid kps + createAddCommitWithKeyPackages cid convId kps withTempKeyPackageFile :: ByteString -> ContT a App FilePath withTempKeyPackageFile bs = do @@ -332,15 +342,19 @@ withTempKeyPackageFile bs = do createAddCommitWithKeyPackages :: (HasCallStack) => ClientIdentity -> + ConvId -> [(ClientIdentity, ByteString)] -> App MessagePackage -createAddCommitWithKeyPackages cid clientsAndKeyPackages = do +createAddCommitWithKeyPackages cid convId clientsAndKeyPackages = do bd <- getBaseDir welcomeFile <- liftIO $ emptyTempFile bd "welcome" giFile <- liftIO $ emptyTempFile bd "gi" + Just conv <- Map.lookup convId . (.convs) <$> getMLSState commit <- runContT (traverse (withTempKeyPackageFile . snd) clientsAndKeyPackages) $ \kpFiles -> mlscli + (Just convId) + conv.ciphersuite cid ( [ "member", "add", @@ -359,7 +373,13 @@ createAddCommitWithKeyPackages cid clientsAndKeyPackages = do modifyMLSState $ \mls -> mls - { newMembers = Set.fromList (map fst clientsAndKeyPackages) + { convs = + Map.adjust + ( \oldConvState -> + oldConvState {newMembers = Set.fromList (map fst clientsAndKeyPackages)} + ) + convId + mls.convs } welcome <- liftIO $ BS.readFile welcomeFile @@ -367,25 +387,30 @@ createAddCommitWithKeyPackages cid clientsAndKeyPackages = do pure $ MessagePackage { sender = cid, + convId = convId, message = commit, welcome = Just welcome, groupInfo = Just gi } -createRemoveCommit :: (HasCallStack) => ClientIdentity -> [ClientIdentity] -> App MessagePackage -createRemoveCommit cid targets = do +createRemoveCommit :: (HasCallStack) => ClientIdentity -> ConvId -> [ClientIdentity] -> App MessagePackage +createRemoveCommit cid convId targets = do bd <- getBaseDir welcomeFile <- liftIO $ emptyTempFile bd "welcome" giFile <- liftIO $ emptyTempFile bd "gi" groupStateMap <- do gs <- getClientGroupState cid - groupData <- assertJust "Group state not initialised" gs.group + groupData <- assertJust "Group state not initialised" (Map.lookup convId gs.groups) Map.fromList <$> readGroupState groupData let indices = map (fromMaybe (error "could not find target") . flip Map.lookup groupStateMap) targets + conv <- getMLSConv convId + commit <- mlscli + (Just convId) + conv.ciphersuite cid ( [ "member", "remove", @@ -408,58 +433,71 @@ createRemoveCommit cid targets = do pure MessagePackage { sender = cid, + convId = convId, message = commit, welcome = Just welcome, groupInfo = Just gi } -createAddProposals :: (HasCallStack) => ClientIdentity -> [Value] -> App [MessagePackage] -createAddProposals cid users = do - mls <- getMLSState +createAddProposals :: (HasCallStack) => ConvId -> ClientIdentity -> [Value] -> App [MessagePackage] +createAddProposals convId cid users = do + Just mls <- Map.lookup convId . (.convs) <$> getMLSState bundles <- for users $ (claimKeyPackages mls.ciphersuite cid >=> getJSON 200) kps <- concat <$> traverse unbundleKeyPackages bundles - traverse (createAddProposalWithKeyPackage cid) kps + traverse (createAddProposalWithKeyPackage convId cid) kps -createReInitProposal :: (HasCallStack) => ClientIdentity -> App MessagePackage -createReInitProposal cid = do +createReInitProposal :: (HasCallStack) => ConvId -> ClientIdentity -> App MessagePackage +createReInitProposal convId cid = do + conv <- getMLSConv convId prop <- mlscli + (Just convId) + conv.ciphersuite cid ["proposal", "--group-in", "", "--group-out", "", "re-init"] Nothing pure MessagePackage { sender = cid, + convId = convId, message = prop, welcome = Nothing, groupInfo = Nothing } createAddProposalWithKeyPackage :: + ConvId -> ClientIdentity -> (ClientIdentity, ByteString) -> App MessagePackage -createAddProposalWithKeyPackage cid (_, kp) = do +createAddProposalWithKeyPackage convId cid (_, kp) = do + conv <- getMLSConv convId prop <- runContT (withTempKeyPackageFile kp) $ \kpFile -> mlscli + (Just convId) + conv.ciphersuite cid ["proposal", "--group-in", "", "--group-out", "", "add", kpFile] Nothing pure MessagePackage { sender = cid, + convId = convId, message = prop, welcome = Nothing, groupInfo = Nothing } -createPendingProposalCommit :: (HasCallStack) => ClientIdentity -> App MessagePackage -createPendingProposalCommit cid = do +createPendingProposalCommit :: (HasCallStack) => ConvId -> ClientIdentity -> App MessagePackage +createPendingProposalCommit convId cid = do bd <- getBaseDir welcomeFile <- liftIO $ emptyTempFile bd "welcome" pgsFile <- liftIO $ emptyTempFile bd "pgs" + conv <- getMLSConv convId commit <- mlscli + (Just convId) + conv.ciphersuite cid [ "commit", "--group", @@ -478,6 +516,7 @@ createPendingProposalCommit cid = do pure MessagePackage { sender = cid, + convId = convId, message = commit, welcome = welcome, groupInfo = Just pgs @@ -485,18 +524,21 @@ createPendingProposalCommit cid = do createExternalCommit :: (HasCallStack) => + ConvId -> ClientIdentity -> Maybe ByteString -> App MessagePackage -createExternalCommit cid mgi = do +createExternalCommit convId cid mgi = do bd <- getBaseDir giFile <- liftIO $ emptyTempFile bd "gi" - conv <- getConv gi <- case mgi of - Nothing -> getGroupInfo cid conv >>= getBody 200 + Nothing -> getGroupInfo cid convId >>= getBody 200 Just v -> pure v + conv <- getMLSConv convId commit <- mlscli + (Just convId) + conv.ciphersuite cid [ "external-commit", "--group-info-in", @@ -510,7 +552,7 @@ createExternalCommit cid mgi = do modifyMLSState $ \mls -> mls - { newMembers = Set.singleton cid + { convs = Map.adjust (\oldConvState -> oldConvState {newMembers = Set.singleton cid}) convId mls.convs -- This might be a different client than those that have been in the -- group from before. } @@ -519,6 +561,7 @@ createExternalCommit cid mgi = do pure $ MessagePackage { sender = cid, + convId = convId, message = commit, welcome = Nothing, groupInfo = Just newPgs @@ -527,25 +570,13 @@ createExternalCommit cid mgi = do data MLSNotificationTag = MLSNotificationMessageTag | MLSNotificationWelcomeTag deriving (Show, Eq, Ord) --- | Extract a conversation ID (including an optional subconversation) from an --- event object. -eventSubConv :: (HasCallStack) => (MakesValue event) => event -> App Value -eventSubConv event = do - sub <- lookupField event "subconv" - conv <- event %. "qualified_conversation" - objSubConvObject $ - object - [ "parent_qualified_id" .= conv, - "subconv_id" .= sub - ] - -consumingMessages :: (HasCallStack) => MessagePackage -> Codensity App () -consumingMessages mp = Codensity $ \k -> do - mls <- getMLSState +consumingMessages :: (HasCallStack) => MLSProtocol -> MessagePackage -> Codensity App () +consumingMessages mlsProtocol mp = Codensity $ \k -> do + conv <- getMLSConv mp.convId -- clients that should receive the message itself - let oldClients = Set.delete mp.sender mls.members + let oldClients = Set.delete mp.sender conv.members -- clients that should receive a welcome message - let newClients = Set.delete mp.sender mls.newMembers + let newClients = Set.delete mp.sender conv.newMembers -- all clients that should receive some MLS notification, together with the -- expected notification tag let clients = @@ -561,10 +592,12 @@ consumingMessages mp = Codensity $ \k -> do r <- k () -- if the conversation is actually MLS (and not mixed), pick one client for - -- each new user and wait for its join event - when (mls.protocol == MLSProtocolMLS) $ + -- each new user and wait for its join event. In Mixed protocol, the user is + -- already in the conversation so they do not get a member-join + -- notification. + when (mlsProtocol == MLSProtocolMLS) $ traverse_ - (awaitMatch isMemberJoinNotif) + (awaitMatch (\n -> isMemberJoinNotif n)) ( flip Map.restrictKeys newUsers . Map.mapKeys ((.user) . fst) . Map.fromList @@ -575,50 +608,53 @@ consumingMessages mp = Codensity $ \k -> do -- at this point we know that every new user has been added to the -- conversation for_ (zip clients wss) $ \((cid, t), ws) -> case t of - MLSNotificationMessageTag -> void $ consumeMessageNoExternal cid (Just mp) ws + MLSNotificationMessageTag -> void $ consumeMessageNoExternal conv.ciphersuite cid mp ws MLSNotificationWelcomeTag -> consumeWelcome cid mp ws pure r -consumeMessageWithPredicate :: (HasCallStack) => (Value -> App Bool) -> ClientIdentity -> Maybe MessagePackage -> WebSocket -> App Value -consumeMessageWithPredicate p cid mmp ws = do - mls <- getMLSState +consumeMessageWithPredicate :: (HasCallStack) => (Value -> App Bool) -> ConvId -> Ciphersuite -> ClientIdentity -> Maybe MessagePackage -> WebSocket -> App Value +consumeMessageWithPredicate p convId cs cid mmp ws = do notif <- awaitMatch p ws event <- notif %. "payload.0" + event %. "qualified_conversation" `shouldMatch` convIdToQidObject convId + lookupField event "subconv" `shouldMatch` convId.subconvId + for_ mmp $ \mp -> do - shouldMatch (eventSubConv event) (fromMaybe A.Null mls.convId) - shouldMatch (event %. "from") mp.sender.user - shouldMatch (event %. "data") (B8.unpack (Base64.encode mp.message)) + event %. "from" `shouldMatch` mp.sender.user + event %. "data" `shouldMatch` (B8.unpack (Base64.encode mp.message)) msgData <- event %. "data" & asByteString - _ <- mlsCliConsume cid msgData - showMessage cid msgData + _ <- mlsCliConsume convId cs cid msgData + showMessage cs cid msgData -- | Get a single MLS message from a websocket and consume it. Return a JSON -- representation of the message. -consumeMessage :: (HasCallStack) => ClientIdentity -> Maybe MessagePackage -> WebSocket -> App Value +consumeMessage :: (HasCallStack) => ConvId -> Ciphersuite -> ClientIdentity -> Maybe MessagePackage -> WebSocket -> App Value consumeMessage = consumeMessageWithPredicate isNewMLSMessageNotif -- | like 'consumeMessage' but will not consume a message where the sender is the backend -consumeMessageNoExternal :: (HasCallStack) => ClientIdentity -> Maybe MessagePackage -> WebSocket -> App Value -consumeMessageNoExternal cid = consumeMessageWithPredicate isNewMLSMessageNotifButNoProposal cid +consumeMessageNoExternal :: (HasCallStack) => Ciphersuite -> ClientIdentity -> MessagePackage -> WebSocket -> App Value +consumeMessageNoExternal cs cid mp = consumeMessageWithPredicate isNewMLSMessageNotifButNoProposal mp.convId cs cid (Just mp) where -- the backend (correctly) reacts to a commit removing someone from a parent conversation with a -- remove proposal, however, we don't want to consume this here isNewMLSMessageNotifButNoProposal :: Value -> App Bool isNewMLSMessageNotifButNoProposal n = do - isNotif <- isNewMLSMessageNotif n - if isNotif + isRelevantNotif <- isNewMLSMessageNotif n &&~ isNotifConvId mp.convId n + if isRelevantNotif then do - msg <- n %. "payload.0.data" & asByteString >>= showMessage cid + msg <- n %. "payload.0.data" & asByteString >>= showMessage cs cid sender <- msg `lookupField` "message.content.sender" `catch` \(_ :: AssertionFailure) -> pure Nothing let backendSender = object ["External" .= Number 0] pure $ sender /= Just backendSender else pure False -mlsCliConsume :: (HasCallStack) => ClientIdentity -> ByteString -> App ByteString -mlsCliConsume cid msgData = +mlsCliConsume :: (HasCallStack) => ConvId -> Ciphersuite -> ClientIdentity -> ByteString -> App ByteString +mlsCliConsume convId cs cid msgData = mlscli + (Just convId) + cs cid [ "consume", "--group", @@ -632,58 +668,74 @@ mlsCliConsume cid msgData = -- | Send an MLS message, wait for clients to receive it, then consume it on -- the client side. If the message is a commit, the -- 'sendAndConsumeCommitBundle' function should be used instead. +-- +-- returns response body of 'postMLSMessage' sendAndConsumeMessage :: (HasCallStack) => MessagePackage -> App Value sendAndConsumeMessage mp = lowerCodensity $ do - consumingMessages mp + consumingMessages MLSProtocolMLS mp lift $ postMLSMessage mp.sender mp.message >>= getJSON 201 +sendAndConsumeCommitBundle :: (HasCallStack) => MessagePackage -> App Value +sendAndConsumeCommitBundle = sendAndConsumeCommitBundleWithProtocol MLSProtocolMLS + -- | Send an MLS commit bundle, wait for clients to receive it, consume it, and -- update the test state accordingly. -sendAndConsumeCommitBundle :: (HasCallStack) => MessagePackage -> App Value -sendAndConsumeCommitBundle mp = do +sendAndConsumeCommitBundleWithProtocol :: (HasCallStack) => MLSProtocol -> MessagePackage -> App Value +sendAndConsumeCommitBundleWithProtocol protocol mp = do lowerCodensity $ do - consumingMessages mp + consumingMessages protocol mp lift $ do r <- postMLSCommitBundle mp.sender (mkBundle mp) >>= getJSON 201 -- if the sender is a new member (i.e. it's an external commit), then -- process the welcome message directly do - mls <- getMLSState - when (Set.member mp.sender mls.newMembers) $ - traverse_ (fromWelcome mp.sender) mp.welcome + conv <- getMLSConv mp.convId + when (Set.member mp.sender conv.newMembers) $ + traverse_ (fromWelcome mp.convId conv.ciphersuite mp.sender) mp.welcome -- increment epoch and add new clients modifyMLSState $ \mls -> mls - { epoch = epoch mls + 1, - members = members mls <> newMembers mls, - newMembers = mempty + { convs = + Map.adjust + ( \conv -> + conv + { epoch = conv.epoch + 1, + members = conv.members <> conv.newMembers, + newMembers = mempty + } + ) + mp.convId + mls.convs } pure r consumeWelcome :: (HasCallStack) => ClientIdentity -> MessagePackage -> WebSocket -> App () consumeWelcome cid mp ws = do - mls <- getMLSState notif <- awaitMatch isWelcomeNotif ws event <- notif %. "payload.0" - shouldMatch (eventSubConv event) (fromMaybe A.Null mls.convId) - shouldMatch (event %. "from") mp.sender.user - shouldMatch (event %. "data") (fmap (B8.unpack . Base64.encode) mp.welcome) + event %. "qualified_conversation" `shouldMatch` convIdToQidObject mp.convId + lookupField event "subconv" `shouldMatch` mp.convId.subconvId + event %. "from" `shouldMatch` mp.sender.user + event %. "data" `shouldMatch` (fmap (B8.unpack . Base64.encode) mp.welcome) welcome <- event %. "data" & asByteString gs <- getClientGroupState cid assertBool "Existing clients in a conversation should not consume welcomes" - (isNothing gs.group) - fromWelcome cid welcome + (not $ Map.member mp.convId gs.groups) + conv <- getMLSConv mp.convId + fromWelcome mp.convId conv.ciphersuite cid welcome -fromWelcome :: ClientIdentity -> ByteString -> App () -fromWelcome cid welcome = +fromWelcome :: ConvId -> Ciphersuite -> ClientIdentity -> ByteString -> App () +fromWelcome convId cs cid welcome = void $ mlscli + (Just convId) + cs cid [ "group", "from-welcome", @@ -733,9 +785,9 @@ setClientGroupState cid g = modifyMLSState $ \s -> s {clientGroupState = Map.insert cid g (clientGroupState s)} -showMessage :: (HasCallStack) => ClientIdentity -> ByteString -> App Value -showMessage cid msg = do - bs <- mlscli cid ["show", "message", "-"] (Just msg) +showMessage :: (HasCallStack) => Ciphersuite -> ClientIdentity -> ByteString -> App Value +showMessage cs cid msg = do + bs <- mlscli Nothing cs cid ["show", "message", "-"] (Just msg) assertOne (Aeson.decode (BS.fromStrict bs)) readGroupState :: (HasCallStack) => ByteString -> App [(ClientIdentity, Word32)] @@ -760,12 +812,16 @@ readGroupState gs = do createApplicationMessage :: (HasCallStack) => + ConvId -> ClientIdentity -> String -> App MessagePackage -createApplicationMessage cid messageContent = do +createApplicationMessage convId cid messageContent = do + conv <- getMLSConv convId message <- mlscli + (Just convId) + conv.ciphersuite cid ["message", "--group-in", "", messageContent, "--group-out", ""] Nothing @@ -773,36 +829,37 @@ createApplicationMessage cid messageContent = do pure MessagePackage { sender = cid, + convId = convId, message = message, welcome = Nothing, groupInfo = Nothing } -setMLSCiphersuite :: Ciphersuite -> App () -setMLSCiphersuite suite = modifyMLSState $ \mls -> mls {ciphersuite = suite} - -leaveCurrentConv :: +leaveConv :: (HasCallStack) => + ConvId -> ClientIdentity -> App () -leaveCurrentConv cid = do - mls <- getMLSState - (_, mSubId) <- objSubConv mls.convId - case mSubId of +leaveConv convId cid = do + case convId.subconvId of -- FUTUREWORK: implement leaving main conversation as well Nothing -> assertFailure "Leaving conversations is not supported" Just _ -> do - void $ leaveSubConversation cid mls.convId >>= getBody 200 + void $ leaveSubConversation cid convId >>= getBody 200 modifyMLSState $ \s -> s - { members = Set.difference mls.members (Set.singleton cid) + { convs = Map.adjust (\conv -> conv {members = Set.delete cid conv.members}) convId s.convs } -getCurrentConv :: (HasCallStack) => ClientIdentity -> App Value -getCurrentConv cid = do - mls <- getMLSState - (conv, mSubId) <- objSubConv mls.convId - resp <- case mSubId of - Nothing -> getConversation cid conv - Just sub -> getSubConversation cid conv sub +getConv :: (HasCallStack) => ConvId -> ClientIdentity -> App Value +getConv convId cid = do + resp <- case convId.subconvId of + Nothing -> getConversation cid (convIdToQidObject convId) + Just sub -> getSubConversation cid convId sub getJSON 200 resp + +getSubConvId :: (MakesValue user, HasCallStack) => user -> ConvId -> String -> App ConvId +getSubConvId user convId subConvName = + getSubConversation user convId subConvName + >>= getJSON 200 + >>= objConvId diff --git a/integration/test/Notifications.hs b/integration/test/Notifications.hs index d99b46b8897..e5a0c59b2d3 100644 --- a/integration/test/Notifications.hs +++ b/integration/test/Notifications.hs @@ -47,7 +47,7 @@ assertNoNotifications u uc since0 p = do awaitNotifications :: (HasCallStack, MakesValue user, MakesValue client) => user -> - client -> + Maybe client -> Maybe String -> -- | Max no. of notifications Int -> @@ -62,11 +62,11 @@ awaitNotifications user client since0 n selector = do | timeRemaining <= 0 = pure res0 | otherwise = do - c <- make client & asString + c <- for client (asString . make) notifs <- getNotifications user - def {since = since, client = Just c} + def {since = since, client = c} `bindResponse` \resp -> asList (resp.json %. "notifications") lastNotifId <- case notifs of [] -> pure since @@ -85,16 +85,26 @@ awaitNotifications user client since0 n selector = do threadDelay 1_000 go (timeRemaining - 1) lastNotifId res -awaitNotification :: +awaitNotificationClient :: (HasCallStack, MakesValue user, MakesValue client, MakesValue lastNotifId) => user -> client -> Maybe lastNotifId -> (Value -> App Bool) -> App Value -awaitNotification user client lastNotifId selector = do +awaitNotificationClient user client lastNotifId selector = do + since0 <- mapM objId lastNotifId + head <$> awaitNotifications user (Just client) since0 1 selector + +awaitNotification :: + (HasCallStack, MakesValue user, MakesValue lastNotifId) => + user -> + Maybe lastNotifId -> + (Value -> App Bool) -> + App Value +awaitNotification user lastNotifId selector = do since0 <- mapM objId lastNotifId - head <$> awaitNotifications user client since0 1 selector + head <$> awaitNotifications user (Nothing :: Maybe ()) since0 1 selector isDeleteUserNotif :: (MakesValue a) => a -> App Bool isDeleteUserNotif n = @@ -127,6 +137,12 @@ isConvLeaveNotifWithLeaver user n = isNotifConv :: (MakesValue conv, MakesValue a, HasCallStack) => conv -> a -> App Bool isNotifConv conv n = fieldEquals n "payload.0.qualified_conversation" (objQidObject conv) +isNotifConvId :: (MakesValue a, HasCallStack) => ConvId -> a -> App Bool +isNotifConvId conv n = do + let subconvField = "payload.0.subconv" + fieldEquals n "payload.0.qualified_conversation" (convIdToQidObject conv) + &&~ maybe (isNothing <$> lookupField n subconvField) (fieldEquals n subconvField) conv.subconvId + isNotifForUser :: (MakesValue user, MakesValue a, HasCallStack) => user -> a -> App Bool isNotifForUser user n = fieldEquals n "payload.0.data.qualified_user_ids.0" (objQidObject user) @@ -219,9 +235,9 @@ assertLeaveNotification :: App () assertLeaveNotification fromUser conv user client leaver = void - $ awaitNotification + $ awaitNotificationClient user - client + (Just client) noValue ( allPreds [ isConvLeaveNotif, diff --git a/integration/test/SetupHelpers.hs b/integration/test/SetupHelpers.hs index 4e19ae9b0a6..15c2c128219 100644 --- a/integration/test/SetupHelpers.hs +++ b/integration/test/SetupHelpers.hs @@ -9,11 +9,13 @@ import API.Cargohold import API.Common import API.Galley import API.GalleyInternal (legalholdWhitelistTeam) +import API.Spar import Control.Monad.Reader import Crypto.Random (getRandomBytes) import Data.Aeson hiding ((.=)) import qualified Data.Aeson.Types as Aeson import qualified Data.ByteString.Base16 as Base16 +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 @@ -22,12 +24,22 @@ import Data.Function import Data.String.Conversions (cs) import qualified Data.Text as Text import Data.Text.Encoding (decodeUtf8) +import qualified Data.UUID as UUID import Data.UUID.V1 (nextUUID) import Data.UUID.V4 (nextRandom) import Data.Vector (fromList) import GHC.Stack +import qualified SAML2.WebSSO as SAML +import qualified SAML2.WebSSO.API.Example as SAML +import qualified SAML2.WebSSO.Test.MockResponse as SAML +import SAML2.WebSSO.Test.Util (SampleIdP (..), makeSampleIdPMetadata) +import Testlib.JSON import Testlib.MockIntegrationService (mkLegalHoldSettings) import Testlib.Prelude +import qualified Text.XML as XML +import qualified Text.XML.Cursor as XML +import qualified Text.XML.DSig as SAML +import UnliftIO (pooledForConcurrentlyN) randomUser :: (HasCallStack, MakesValue domain) => domain -> CreateUser -> App Value randomUser domain cu = bindResponse (createUser domain cu) $ \resp -> do @@ -43,7 +55,7 @@ createTeam :: (HasCallStack, MakesValue domain) => domain -> Int -> App (Value, createTeam domain memberCount = do owner <- createUser domain def {team = True} >>= getJSON 201 tid <- owner %. "team" & asString - members <- for [2 .. memberCount] $ \_ -> createTeamMember owner def + members <- pooledForConcurrentlyN 64 [2 .. memberCount] $ \_ -> createTeamMember owner def pure (owner, tid, members) data CreateTeamMember = CreateTeamMember @@ -131,7 +143,7 @@ getAllConvs u = do simpleMixedConversationSetup :: (HasCallStack, MakesValue domain) => domain -> - App (Value, Value, Value) + App (Value, Value, ConvId) simpleMixedConversationSetup secondDomain = do (alice, tid, _) <- createTeam OwnDomain 1 bob <- randomUser secondDomain def @@ -140,15 +152,17 @@ simpleMixedConversationSetup secondDomain = do conv <- postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} >>= getJSON 201 + >>= objConvId bindResponse (putConversationProtocol bob conv "mixed") $ \resp -> do resp.status `shouldMatchInt` 200 - modifyMLSState $ \mls -> mls {protocol = MLSProtocolMixed} - - conv' <- getConversation alice conv >>= getJSON 200 + convId <- + getConversation alice (convIdToQidObject conv) + >>= getJSON 200 + >>= objConvId - pure (alice, bob, conv') + pure (alice, bob, convId) supportMLS :: (HasCallStack, MakesValue u) => u -> App () supportMLS u = do @@ -403,3 +417,97 @@ uploadDownloadProfilePicture :: (HasCallStack, MakesValue usr) => usr -> App (St uploadDownloadProfilePicture usr = do (dom, key, _payload) <- uploadProfilePicture usr downloadProfilePicture usr dom key + +addUsersToFailureContext :: (MakesValue user) => [(String, user)] -> App a -> App a +addUsersToFailureContext namesAndUsers action = do + let mkLine (name, user) = do + (domain, id_) <- objQid user + pure $ name <> ": " <> id_ <> "@" <> domain + allLines <- unlines <$> (mapM mkLine namesAndUsers) + addFailureContext allLines action + +registerTestIdPWithMeta :: (HasCallStack, MakesValue owner) => owner -> App Response +registerTestIdPWithMeta owner = fst <$> registerTestIdPWithMetaWithPrivateCreds owner + +registerTestIdPWithMetaWithPrivateCreds :: (HasCallStack, MakesValue owner) => owner -> App (Response, (SAML.IdPMetadata, SAML.SignPrivCreds)) +registerTestIdPWithMetaWithPrivateCreds owner = do + SampleIdP idpmeta pCreds _ _ <- makeSampleIdPMetadata + (,(idpmeta, pCreds)) <$> createIdp owner idpmeta + +-- | Given a team configured with saml sso, attempt a login with valid credentials. This +-- function simulates client *and* IdP (instead of talking to an IdP). It can be used to test +-- scim-provisioned users as well as saml auto-provisioning without scim. +loginWithSaml :: (HasCallStack) => Bool -> String -> Value -> (String, (SAML.IdPMetadata, SAML.SignPrivCreds)) -> App () +loginWithSaml expectSuccess tid scimUser (iid, (meta, privcreds)) = do + let idpConfig = SAML.IdPConfig (SAML.IdPId (fromMaybe (error "invalid idp id") (UUID.fromString iid))) meta () + spmeta <- getSPMetadata OwnDomain tid + authnreq <- initiateSamlLogin OwnDomain iid + email <- scimUser %. "externalId" >>= asString + let nameId = fromRight (error "could not create name id") $ SAML.emailNameID (cs email) + authnResp <- runSimpleSP $ SAML.mkAuthnResponseWithSubj nameId privcreds idpConfig (toSPMetaData spmeta.body) (parseAuthnReqResp authnreq.body) True + loginResp <- finalizeSamlLogin OwnDomain tid authnResp + validateLoginResp loginResp + where + toSPMetaData :: ByteString -> SAML.SPMetadata + toSPMetaData bs = fromRight (error "could not decode spmetatdata") $ SAML.decode $ cs bs + + validateLoginResp :: (HasCallStack) => Response -> App () + validateLoginResp resp = + if expectSuccess + then do + resp.status `shouldMatchInt` 200 + let bdy = cs resp.body + bdy `shouldContain` "" + bdy `shouldContain` "" + bdy `shouldContain` "" + bdy `shouldContain` "wire:sso:success" + bdy `shouldContain` "window.opener.postMessage({type: 'AUTH_SUCCESS'}, receiverOrigin)" + hasPersistentCookieHeader resp + else do + resp.status `shouldMatchInt` 200 + let bdy = cs resp.body + bdy `shouldContain` "" + bdy `shouldContain` "" + bdy `shouldContain` "wire:sso:error:" + bdy `shouldContain` "window.opener.postMessage({" + bdy `shouldContain` "\"type\":\"AUTH_ERROR\"" + bdy `shouldContain` "\"payload\":{" + bdy `shouldContain` "\"label\":\"forbidden\"" + bdy `shouldContain` "}, receiverOrigin)" + hasPersistentCookieHeader resp + + hasPersistentCookieHeader :: Response -> App () + hasPersistentCookieHeader rsp = do + let cookie = getCookie "zuid" rsp + case cookie of + Nothing -> expectSuccess `shouldMatch` False + Just _ -> expectSuccess `shouldMatch` True + + runSimpleSP :: SAML.SimpleSP a -> App a + runSimpleSP action = liftIO $ do + ctx <- SAML.mkSimpleSPCtx undefined [] + result <- SAML.runSimpleSP ctx action + pure $ fromRight (error "simple sp action failed") result + + parseAuthnReqResp :: + ByteString -> + SAML.AuthnRequest + parseAuthnReqResp bs = reqBody + where + xml :: XML.Document + xml = + fromRight (error "malformed html in response body") $ + XML.parseText XML.def (cs bs) + + reqBody :: SAML.AuthnRequest + reqBody = + (XML.fromDocument xml XML.$// XML.element (XML.Name (cs "input") (Just (cs "http://www.w3.org/1999/xhtml")) Nothing)) + & head + & XML.attribute (fromString "value") + & head + & cs + & EL.decode + & fromRight (error "") + & cs + & SAML.decodeElem + & fromRight (error "") diff --git a/integration/test/Test/AccessUpdate.hs b/integration/test/Test/AccessUpdate.hs index 01113946788..14c921fefe7 100644 --- a/integration/test/Test/AccessUpdate.hs +++ b/integration/test/Test/AccessUpdate.hs @@ -78,16 +78,17 @@ testAccessUpdateGuestRemoved proto = do >>= getJSON 201 pure (conv, clients) ConversationProtocolMLS -> do - alice1 <- createMLSClient def alice - clients <- traverse (createMLSClient def) [bob, charlie, dee] - traverse_ uploadNewKeyPackage clients + alice1 <- createMLSClient def def alice + clients <- traverse (createMLSClient def def) [bob, charlie, dee] + traverse_ (uploadNewKeyPackage def) clients conv <- postConversation alice1 defMLS {team = Just tid} >>= getJSON 201 - createGroup alice1 conv + convId <- objConvId conv + createGroup def alice1 convId - void $ createAddCommit alice1 [bob, charlie, dee] >>= sendAndConsumeCommitBundle - convId <- conv %. "qualified_id" - pure (convId, map (.client) (alice1 : clients)) + void $ createAddCommit alice1 convId [bob, charlie, dee] >>= sendAndConsumeCommitBundle + convQid <- conv %. "qualified_id" + pure (convQid, map (.client) (alice1 : clients)) let update = ["access" .= ([] :: [String]), "access_role" .= ["team_member"]] void $ updateAccess alice conv update >>= getJSON 200 diff --git a/integration/test/Test/Brig.hs b/integration/test/Test/Brig.hs index f55fc952b00..1aa04e6ed18 100644 --- a/integration/test/Test/Brig.hs +++ b/integration/test/Test/Brig.hs @@ -14,7 +14,6 @@ import Data.String.Conversions import qualified Data.UUID as UUID import qualified Data.UUID.V4 as UUID import GHC.Stack -import SAML2.WebSSO.Test.Util (SampleIdP (..), makeSampleIdPMetadata) import SetupHelpers import System.IO.Extra import Testlib.Assertions @@ -246,7 +245,7 @@ testDeleteEmail = do associateUsrWithSSO = do void $ setTeamFeatureStatus owner tid "sso" "enabled" registerTestIdPWithMeta owner >>= assertSuccess - tok <- createScimToken owner >>= getJSON 200 >>= (%. "token") >>= asString + tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString void $ findUsersByExternalId owner tok email searchShouldBe :: (HasCallStack) => String -> App () @@ -264,8 +263,3 @@ testDeleteEmail = do associateUsrWithSSO deleteSelfEmail usr >>= assertSuccess searchShouldBe "empty" - -registerTestIdPWithMeta :: (HasCallStack, MakesValue owner) => owner -> App Response -registerTestIdPWithMeta owner = do - SampleIdP idpmeta _ _ _ <- makeSampleIdPMetadata - createIdp owner idpmeta diff --git a/integration/test/Test/Client.hs b/integration/test/Test/Client.hs index 8c3101737dd..029226f3bca 100644 --- a/integration/test/Test/Client.hs +++ b/integration/test/Test/Client.hs @@ -104,8 +104,8 @@ testUpdateClientWithConsumableNotificationsCapability = do resp.status `shouldMatchInt` 200 resp.json %. "0.capabilities" `shouldMatch` [consumeCapability] -testGetClientCapabilitiesV6 :: App () -testGetClientCapabilitiesV6 = do +testGetClientCapabilitiesV7 :: App () +testGetClientCapabilitiesV7 = do let allCapabilities = ["legalhold-implicit-consent", "consumable-notifications"] alice <- randomUser OwnDomain def addClient alice def {acapabilities = Just allCapabilities} `bindResponse` \resp -> do @@ -116,8 +116,16 @@ testGetClientCapabilitiesV6 = do resp.status `shouldMatchInt` 200 resp.json %. "0.capabilities" `shouldMatchSet` allCapabilities - -- In API v6 and below, the "capabilities" field is an enum, so having a new - -- value for this enum is a breaking change. + -- The "capabilities" field is an enum, so having a new value for this enum is + -- a breaking change. So in API v7 and below, we should not see the + -- "consumable-notifications" value. + withAPIVersion 7 $ getSelfClients alice `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "0.capabilities" `shouldMatchSet` ["legalhold-implicit-consent"] + + -- In API v6 and below, the "capabilities" field is doubly nested. However, + -- the consumable-notifications value should not be considered part of the + -- enum. withAPIVersion 6 $ getSelfClients alice `bindResponse` \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "0.capabilities.capabilities" `shouldMatchSet` ["legalhold-implicit-consent"] diff --git a/integration/test/Test/Conversation.hs b/integration/test/Test/Conversation.hs index a9ef7595714..3ae4a379e5d 100644 --- a/integration/test/Test/Conversation.hs +++ b/integration/test/Test/Conversation.hs @@ -492,15 +492,14 @@ testSynchroniseUserRemovalNotification domain = do otherDomain <- make domain [alice, bob] <- createAndConnectUsers [ownDomain, otherDomain] runCodensity (acquireResources 1 resourcePool) $ \[dynBackend] -> do - (conv, charlie, client) <- + (conv, charlie) <- runCodensity (startDynamicBackend dynBackend mempty) $ \_ -> do charlie <- randomUser dynBackend.berDomain def - client <- objId $ bindResponse (addClient charlie def) $ getJSON 201 mapM_ (connectTwoUsers charlie) [alice, bob] conv <- postConversation alice (defProteus {qualifiedUsers = [bob, charlie]}) >>= getJSON 201 - pure (conv, charlie, client) + pure (conv, charlie) let newConvName = "The new conversation name" bindResponse (changeConversationName alice conv newConvName) $ \resp -> @@ -508,10 +507,10 @@ testSynchroniseUserRemovalNotification domain = do bindResponse (removeMember alice conv charlie) $ \resp -> resp.status `shouldMatchInt` 200 runCodensity (startDynamicBackend dynBackend mempty) $ \_ -> do - nameNotif <- awaitNotification charlie client noValue isConvNameChangeNotif + nameNotif <- awaitNotification charlie noValue isConvNameChangeNotif nameNotif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv nameNotif %. "payload.0.data.name" `shouldMatch` newConvName - leaveNotif <- awaitNotification charlie client noValue isConvLeaveNotif + leaveNotif <- awaitNotification charlie noValue isConvLeaveNotif leaveNotif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv testConvRenaming :: (HasCallStack) => App () @@ -648,19 +647,18 @@ testDeleteTeamConversationWithUnreachableRemoteMembers = do notif %. "payload.0.qualified_from" `shouldMatch` objQidObject alice runCodensity (acquireResources 1 resourcePool) $ \[dynBackend] -> do - (bob, bobClient) <- runCodensity (startDynamicBackend dynBackend mempty) $ \_ -> do + bob <- runCodensity (startDynamicBackend dynBackend mempty) $ \_ -> do bob <- randomUser dynBackend.berDomain def - bobClient <- objId $ bindResponse (addClient bob def) $ getJSON 201 connectTwoUsers alice bob mem <- bob %. "qualified_id" void $ addMembers alice conv def {users = [mem]} >>= getBody 200 - pure (bob, bobClient) + pure bob withWebSocket alice $ \ws -> do void $ deleteTeamConversation team conv alice >>= getBody 200 notif <- awaitMatch isConvDeleteNotif ws assertNotification notif void $ runCodensity (startDynamicBackend dynBackend mempty) $ \_ -> do - notif <- awaitNotification bob bobClient noValue isConvDeleteNotif + notif <- awaitNotification bob noValue isConvDeleteNotif assertNotification notif testDeleteTeamMemberLimitedEventFanout :: (HasCallStack) => App () diff --git a/integration/test/Test/Events.hs b/integration/test/Test/Events.hs new file mode 100644 index 00000000000..c15b4bf8803 --- /dev/null +++ b/integration/test/Test/Events.hs @@ -0,0 +1,515 @@ +module Test.Events where + +import API.Brig +import API.BrigCommon +import API.Common +import API.Galley +import API.Gundeck +import qualified Control.Concurrent.Timeout as Timeout +import Control.Monad.Codensity +import Control.Monad.RWS (asks) +import Control.Monad.Trans.Class +import Control.Retry +import Data.ByteString.Conversion (toByteString') +import qualified Data.Text as Text +import Data.Timeout +import Network.AMQP.Extended +import Network.RabbitMqAdmin +import qualified Network.WebSockets as WS +import Notifications +import SetupHelpers +import Testlib.Prelude hiding (assertNoEvent) +import Testlib.ResourcePool (acquireResources) +import UnliftIO hiding (handle) + +testConsumeEventsOneWebSocket :: (HasCallStack) => App () +testConsumeEventsOneWebSocket = do + alice <- randomUser OwnDomain def + + lastNotifResp <- + retrying + (constantDelay 10_000 <> limitRetries 10) + (\_ resp -> pure $ resp.status == 404) + (\_ -> getLastNotification alice def) + lastNotifId <- lastNotifResp.json %. "id" & asString + + client <- addClient alice def {acapabilities = Just ["consumable-notifications"]} >>= getJSON 201 + clientId <- objId client + + runCodensity (createEventsWebSocket alice clientId) $ \ws -> do + deliveryTag <- assertEvent ws $ \e -> do + e %. "type" `shouldMatch` "event" + e %. "data.event.payload.0.type" `shouldMatch` "user.client-add" + e %. "data.event.payload.0.client.id" `shouldMatch` clientId + e %. "data.delivery_tag" + assertNoEvent_ ws + + sendAck ws deliveryTag False + assertNoEvent_ ws + + handle <- randomHandle + putHandle alice handle >>= assertSuccess + + assertEvent ws $ \e -> do + e %. "type" `shouldMatch` "event" + e %. "data.event.payload.0.type" `shouldMatch` "user.update" + e %. "data.event.payload.0.user.handle" `shouldMatch` handle + + -- No new notifications should be stored in Cassandra as the user doesn't have + -- any legacy clients + getNotifications alice def {since = Just lastNotifId} `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + shouldBeEmpty $ resp.json %. "notifications" + +testConsumeEventsForDifferentUsers :: (HasCallStack) => App () +testConsumeEventsForDifferentUsers = do + alice <- randomUser OwnDomain def + bob <- randomUser OwnDomain def + + aliceClient <- addClient alice def {acapabilities = Just ["consumable-notifications"]} >>= getJSON 201 + aliceClientId <- objId aliceClient + + bobClient <- addClient bob def {acapabilities = Just ["consumable-notifications"]} >>= getJSON 201 + bobClientId <- objId bobClient + + lowerCodensity $ do + aliceWS <- createEventsWebSocket alice aliceClientId + bobWS <- createEventsWebSocket bob bobClientId + lift $ assertClientAdd aliceClientId aliceWS + lift $ assertClientAdd bobClientId bobWS + where + assertClientAdd :: (HasCallStack) => String -> EventWebSocket -> App () + assertClientAdd clientId ws = do + deliveryTag <- assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.client-add" + e %. "data.event.payload.0.client.id" `shouldMatch` clientId + e %. "data.delivery_tag" + assertNoEvent_ ws + sendAck ws deliveryTag False + +testConsumeEventsWhileHavingLegacyClients :: (HasCallStack) => App () +testConsumeEventsWhileHavingLegacyClients = do + alice <- randomUser OwnDomain def + + -- Even if alice has no clients, the notifications should still be persisted + -- in Cassandra. This choice is kinda arbitrary as these notifications + -- probably don't mean much, however, it ensures backwards compatibility. + lastNotifId <- + awaitNotification alice noValue (const $ pure True) >>= \notif -> do + notif %. "payload.0.type" `shouldMatch` "user.activate" + -- There is only one notification (at the time of writing), so we assume + -- it to be the last one. + notif %. "id" & asString + + oldClient <- addClient alice def {acapabilities = Just []} >>= getJSON 201 + + withWebSocket (alice, "anything-but-conn", oldClient %. "id") $ \oldWS -> do + newClient <- addClient alice def {acapabilities = Just ["consumable-notifications"]} >>= getJSON 201 + newClientId <- newClient %. "id" & asString + + oldNotif <- awaitMatch isUserClientAddNotif oldWS + oldNotif %. "payload.0.client.id" `shouldMatch` newClientId + + runCodensity (createEventsWebSocket alice newClientId) $ \ws -> + assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.client-add" + e %. "data.event.payload.0.client.id" `shouldMatch` newClientId + + -- All notifs are also in Cassandra because of the legacy client + getNotifications alice def {since = Just lastNotifId} `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "notifications.0.payload.0.type" `shouldMatch` "user.client-add" + resp.json %. "notifications.1.payload.0.type" `shouldMatch` "user.client-add" + +testConsumeEventsAcks :: (HasCallStack) => App () +testConsumeEventsAcks = do + alice <- randomUser OwnDomain def + client <- addClient alice def {acapabilities = Just ["consumable-notifications"]} >>= getJSON 201 + clientId <- objId client + + runCodensity (createEventsWebSocket alice clientId) $ \ws -> do + assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.client-add" + e %. "data.event.payload.0.client.id" `shouldMatch` clientId + + -- without ack, we receive the same event again + runCodensity (createEventsWebSocket alice clientId) $ \ws -> do + deliveryTag <- assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.client-add" + e %. "data.event.payload.0.client.id" `shouldMatch` clientId + e %. "data.delivery_tag" + sendAck ws deliveryTag False + + runCodensity (createEventsWebSocket alice clientId) $ \ws -> do + assertNoEvent_ ws + +testConsumeEventsMultipleAcks :: (HasCallStack) => App () +testConsumeEventsMultipleAcks = do + alice <- randomUser OwnDomain def + client <- addClient alice def {acapabilities = Just ["consumable-notifications"]} >>= getJSON 201 + clientId <- objId client + + handle <- randomHandle + putHandle alice handle >>= assertSuccess + + runCodensity (createEventsWebSocket alice clientId) $ \ws -> do + assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.client-add" + e %. "data.event.payload.0.client.id" `shouldMatch` clientId + + deliveryTag <- assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.update" + e %. "data.event.payload.0.user.handle" `shouldMatch` handle + e %. "data.delivery_tag" + + sendAck ws deliveryTag True + + runCodensity (createEventsWebSocket alice clientId) $ \ws -> do + assertNoEvent_ ws + +testConsumeEventsAckNewEventWithoutAckingOldOne :: (HasCallStack) => App () +testConsumeEventsAckNewEventWithoutAckingOldOne = do + alice <- randomUser OwnDomain def + client <- addClient alice def {acapabilities = Just ["consumable-notifications"]} >>= getJSON 201 + clientId <- objId client + + handle <- randomHandle + putHandle alice handle >>= assertSuccess + + runCodensity (createEventsWebSocket alice clientId) $ \ws -> do + assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.client-add" + e %. "data.event.payload.0.client.id" `shouldMatch` clientId + + deliveryTagHandleAdd <- assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.update" + e %. "data.event.payload.0.user.handle" `shouldMatch` handle + e %. "data.delivery_tag" + + -- Only ack the handle add delivery tag + sendAck ws deliveryTagHandleAdd False + + -- Expect client-add event to be delivered again. + runCodensity (createEventsWebSocket alice clientId) $ \ws -> do + deliveryTagClientAdd <- assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.client-add" + e %. "data.event.payload.0.client.id" `shouldMatch` clientId + e %. "data.delivery_tag" + + sendAck ws deliveryTagClientAdd False + + runCodensity (createEventsWebSocket alice clientId) $ \ws -> do + assertNoEvent_ ws + +testEventsDeadLettered :: (HasCallStack) => App () +testEventsDeadLettered = do + let notifTTL = 1 # Second + withModifiedBackend (def {gundeckCfg = setField "settings.notificationTTL" (notifTTL #> Second)}) $ \domain -> do + alice <- randomUser domain def + + -- This generates an event + client <- addClient alice def {acapabilities = Just ["consumable-notifications"]} >>= getJSON 201 + clientId <- objId client + + -- We expire the add client event by waiting it out + Timeout.threadDelay (notifTTL + 500 # MilliSecond) + + -- Generate a second event + handle1 <- randomHandle + putHandle alice handle1 >>= assertSuccess + + runCodensity (createEventsWebSocket alice clientId) $ \ws -> do + assertEvent ws $ \e -> do + e %. "type" `shouldMatch` "notifications.missed" + + -- Until we ack the full sync, we can't get new events + ackFullSync ws + + -- withEventsWebSocket alice clientId $ \eventsChan ackChan -> do + -- Now we can see the next event + assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.update" + e %. "data.event.payload.0.user.handle" `shouldMatch` handle1 + ackEvent ws e + + -- We've consumed the whole queue. + assertNoEvent_ ws + +testTransientEventsDoNotTriggerDeadLetters :: (HasCallStack) => App () +testTransientEventsDoNotTriggerDeadLetters = do + let notifTTL = 1 # Second + withModifiedBackend (def {gundeckCfg = setField "settings.notificationTTL" (notifTTL #> Second)}) $ \domain -> do + alice <- randomUser domain def + -- Creates a non-transient event + client <- addClient alice def {acapabilities = Just ["consumable-notifications"]} >>= getJSON 201 + clientId <- objId client + + -- consume it + runCodensity (createEventsWebSocket alice clientId) $ \ws -> do + assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.client-add" + e %. "type" `shouldMatch` "event" + e %. "data.event.payload.0.type" `shouldMatch` "user.client-add" + e %. "data.event.payload.0.client.id" `shouldMatch` clientId + deliveryTag <- e %. "data.delivery_tag" + sendAck ws deliveryTag False + + -- Self conv ID is same as user's ID, we'll use this to send typing + -- indicators, so we don't have to create another conv. + selfConvId <- objQidObject alice + -- Typing status is transient, currently no one is listening. + sendTypingStatus alice selfConvId "started" >>= assertSuccess + + runCodensity (createEventsWebSocket alice clientId) $ \ws -> do + assertNoEvent_ ws + +testTransientEvents :: (HasCallStack) => App () +testTransientEvents = do + alice <- randomUser OwnDomain def + client <- addClient alice def {acapabilities = Just ["consumable-notifications"]} >>= getJSON 201 + clientId <- objId client + + -- Self conv ID is same as user's ID, we'll use this to send typing + -- indicators, so we don't have to create another conv. + selfConvId <- objQidObject alice + + runCodensity (createEventsWebSocket alice clientId) $ \ws -> do + consumeAllEvents ws + sendTypingStatus alice selfConvId "started" >>= assertSuccess + assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "conversation.typing" + e %. "data.event.payload.0.qualified_conversation" `shouldMatch` selfConvId + deliveryTag <- e %. "data.delivery_tag" + sendAck ws deliveryTag False + + handle1 <- randomHandle + putHandle alice handle1 >>= assertSuccess + + sendTypingStatus alice selfConvId "stopped" >>= assertSuccess + + handle2 <- randomHandle + putHandle alice handle2 >>= assertSuccess + + -- We shouldn't see the stopped typing status because we were not connected to + -- the websocket when it was sent. The other events should still show up in + -- order. + runCodensity (createEventsWebSocket alice clientId) $ \ws -> do + for_ [handle1, handle2] $ \handle -> + assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.update" + e %. "data.event.payload.0.user.handle" `shouldMatch` handle + ackEvent ws e + + assertNoEvent_ ws + +testChannelLimit :: (HasCallStack) => App () +testChannelLimit = withModifiedBackend + ( def + { cannonCfg = + setField "rabbitMqMaxChannels" (2 :: Int) + >=> setField "rabbitMqMaxConnections" (1 :: Int) + } + ) + $ \domain -> do + alice <- randomUser domain def + (client0 : clients) <- + replicateM 3 + $ addClient alice def {acapabilities = Just ["consumable-notifications"]} + >>= getJSON 201 + >>= (%. "id") + >>= asString + + lowerCodensity $ do + for_ clients $ \c -> do + ws <- createEventsWebSocket alice c + lift $ assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.client-add" + e %. "data.event.payload.0.client.id" `shouldMatch` c + + -- the first client fails to connect because the server runs out of channels + do + ws <- createEventsWebSocket alice client0 + lift $ assertNoEvent_ ws + +testChannelKilled :: (HasCallStack) => App () +testChannelKilled = lowerCodensity $ do + pool <- lift $ asks (.resourcePool) + [backend] <- acquireResources 1 pool + domain <- startDynamicBackend backend mempty + alice <- lift $ randomUser domain def + [c1, c2] <- + lift + $ replicateM 2 + $ addClient alice def {acapabilities = Just ["consumable-notifications"]} + >>= getJSON 201 + >>= (%. "id") + >>= asString + + ws <- createEventsWebSocket alice c1 + lift $ do + assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.client-add" + e %. "data.event.payload.0.client.id" `shouldMatch` c1 + ackEvent ws e + + assertEvent ws $ \e -> do + e %. "data.event.payload.0.type" `shouldMatch` "user.client-add" + e %. "data.event.payload.0.client.id" `shouldMatch` c2 + + recoverAll + (constantDelay 500_000 <> limitRetries 10) + (const (killConnection backend)) + + noEvent <- assertNoEvent ws + noEvent `shouldMatch` WebSocketDied + +---------------------------------------------------------------------- +-- helpers + +data EventWebSocket = EventWebSocket + { events :: Chan (Either WS.ConnectionException Value), + ack :: MVar (Maybe Value) + } + +createEventsWebSocket :: + (HasCallStack, MakesValue uid) => + uid -> + String -> + Codensity App EventWebSocket +createEventsWebSocket user cid = do + eventsChan <- liftIO newChan + ackChan <- liftIO newEmptyMVar + serviceMap <- lift $ getServiceMap =<< objDomain user + apiVersion <- lift $ getAPIVersionFor $ objDomain user + let minAPIVersion = 8 + lift + . when (apiVersion < minAPIVersion) + $ assertFailure ("Events websocket can only be created when APIVersion is at least " <> show minAPIVersion) + + uid <- lift $ objId =<< objQidObject user + let HostPort caHost caPort = serviceHostPort serviceMap Cannon + path = "/v" <> show apiVersion <> "/events?client=" <> cid + caHdrs = [(fromString "Z-User", toByteString' uid)] + app conn = + race_ + (wsRead conn `catch` (writeChan eventsChan . Left)) + (wsWrite conn) + + wsRead conn = forever $ do + bs <- WS.receiveData conn + case decodeStrict' bs of + Just n -> writeChan eventsChan (Right n) + Nothing -> + error $ "Failed to decode events: " ++ show bs + + wsWrite conn = do + mAck <- takeMVar ackChan + case mAck of + Nothing -> WS.sendClose conn (Text.pack "") + Just ack -> + WS.sendBinaryData conn (encode ack) + >> wsWrite conn + + wsThread <- Codensity $ \k -> do + withAsync + ( liftIO + $ WS.runClientWith + caHost + (fromIntegral caPort) + path + WS.defaultConnectionOptions + caHdrs + app + ) + k + + Codensity $ \k -> + k (EventWebSocket eventsChan ackChan) `finally` do + putMVar ackChan Nothing + liftIO $ wait wsThread + +ackFullSync :: (HasCallStack) => EventWebSocket -> App () +ackFullSync ws = + putMVar ws.ack + $ Just (object ["type" .= "ack_full_sync"]) + +ackEvent :: (HasCallStack) => EventWebSocket -> Value -> App () +ackEvent ws event = do + deliveryTag <- event %. "data.delivery_tag" + sendAck ws deliveryTag False + +sendAck :: (HasCallStack) => EventWebSocket -> Value -> Bool -> App () +sendAck ws deliveryTag multiple = + do + putMVar $ ws.ack + $ Just + $ object + [ "type" .= "ack", + "data" + .= object + [ "delivery_tag" .= deliveryTag, + "multiple" .= multiple + ] + ] + +assertEvent :: (HasCallStack) => EventWebSocket -> ((HasCallStack) => Value -> App a) -> App a +assertEvent ws expectations = do + timeout 10_000_000 (readChan ws.events) >>= \case + Nothing -> assertFailure "No event received for 1s" + Just (Left _) -> assertFailure "Websocket closed when waiting for more events" + Just (Right e) -> do + pretty <- prettyJSON e + addFailureContext ("event:\n" <> pretty) + $ expectations e + +data NoEvent = NoEvent | WebSocketDied + +instance ToJSON NoEvent where + toJSON NoEvent = toJSON "no-event" + toJSON WebSocketDied = toJSON "web-socket-died" + +assertNoEvent :: (HasCallStack) => EventWebSocket -> App NoEvent +assertNoEvent ws = do + timeout 1_000_000 (readChan ws.events) >>= \case + Nothing -> pure NoEvent + Just (Left _) -> pure WebSocketDied + Just (Right e) -> do + eventJSON <- prettyJSON e + assertFailure $ "Did not expect event: \n" <> eventJSON + +assertNoEvent_ :: (HasCallStack) => EventWebSocket -> App () +assertNoEvent_ = void . assertNoEvent + +consumeAllEvents :: EventWebSocket -> App () +consumeAllEvents ws = do + timeout 1_000_000 (readChan ws.events) >>= \case + Nothing -> pure () + Just (Left e) -> + assertFailure + $ "Websocket closed while consuming all events: " + <> displayException e + Just (Right e) -> do + ackEvent ws e + consumeAllEvents ws + +killConnection :: (HasCallStack) => BackendResource -> App () +killConnection backend = do + rc <- asks (.rabbitMQConfig) + let opts = + RabbitMqAdminOpts + { host = rc.host, + port = 0, + adminPort = fromIntegral rc.adminPort, + vHost = Text.pack backend.berVHost, + tls = Just $ RabbitMqTlsOpts Nothing True + } + servantClient <- liftIO $ mkRabbitMqAdminClientEnv opts + name <- do + connections <- liftIO $ listConnectionsByVHost servantClient opts.vHost + connection <- + assertOne + [ c | c <- connections, c.userProvidedName == Just (Text.pack "pool 0") + ] + pure connection.name + + void $ liftIO $ deleteConnection servantClient name diff --git a/integration/test/Test/ExternalPartner.hs b/integration/test/Test/ExternalPartner.hs index 01bdd629834..4c9f748e564 100644 --- a/integration/test/Test/ExternalPartner.hs +++ b/integration/test/Test/ExternalPartner.hs @@ -60,7 +60,7 @@ testExternalPartnerPermissionsMls = do -- external partners should not be able to create (MLS) conversations (owner, _, _) <- createTeam OwnDomain 2 bobExt <- createTeamMember owner def {role = "partner"} - bobExtClient <- createMLSClient def bobExt + bobExtClient <- createMLSClient def def bobExt bindResponse (postConversation bobExtClient defMLS) $ \resp -> do resp.status `shouldMatchInt` 403 diff --git a/integration/test/Test/FeatureFlags/LegalHold.hs b/integration/test/Test/FeatureFlags/LegalHold.hs index 55743ec4f91..45f099aef5c 100644 --- a/integration/test/Test/FeatureFlags/LegalHold.hs +++ b/integration/test/Test/FeatureFlags/LegalHold.hs @@ -111,7 +111,8 @@ testExposeInvitationURLsToTeamAdminConfig = do runCodensity (acquireResources 1 resourcePool) $ \[testBackend] -> do let domain = testBackend.berDomain - let testNoAllowlistEntry = runCodensity (startDynamicBackend testBackend $ cfgExposeInvitationURLsTeamAllowlist ([] :: [String])) $ \_ -> do + testNoAllowlistEntry :: (HasCallStack) => App (Value, String) + testNoAllowlistEntry = runCodensity (startDynamicBackend testBackend $ cfgExposeInvitationURLsTeamAllowlist ([] :: [String])) $ \_ -> do (owner, tid, _) <- createTeam domain 1 checkFeature "exposeInvitationURLsToTeamAdmin" owner tid disabledLocked -- here we get a response with HTTP status 200 and feature status unchanged (disabled), which we find weird, but we're just testing the current behavior diff --git a/integration/test/Test/Federation.hs b/integration/test/Test/Federation.hs index ff1f2ae2304..7bd0c888ac0 100644 --- a/integration/test/Test/Federation.hs +++ b/integration/test/Test/Federation.hs @@ -114,7 +114,7 @@ testNotificationsForOfflineBackends = do objQid delUserDeletedNotif `shouldMatch` objQid delUser runCodensity (startDynamicBackend downBackend mempty) $ \_ -> do - newMsgNotif <- awaitNotification downUser1 downClient1 noValue isNewMessageNotif + newMsgNotif <- awaitNotificationClient downUser1 downClient1 noValue isNewMessageNotif newMsgNotif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject upBackendConv newMsgNotif %. "payload.0.data.text" `shouldMatchBase64` "success message for down user" @@ -124,11 +124,11 @@ testNotificationsForOfflineBackends = do isNotifConv downBackendConv, isNotifForUser delUser ] - void $ awaitNotification downUser1 downClient1 (Just newMsgNotif) isDelUserLeaveDownConvNotif + void $ awaitNotificationClient downUser1 (Just downClient1) (Just newMsgNotif) isDelUserLeaveDownConvNotif -- FUTUREWORK: Uncomment after fixing this bug: https://wearezeta.atlassian.net/browse/WPB-3664 -- void $ awaitNotification downUser1 downClient1 (Just newMsgNotif) 1 isOtherUser2LeaveUpConvNotif -- void $ awaitNotification otherUser otherClient (Just newMsgNotif) isDelUserLeaveDownConvNotif - delUserDeletedNotif <- nPayload $ awaitNotification downUser1 downClient1 (Just newMsgNotif) isDeleteUserNotif + delUserDeletedNotif <- nPayload $ awaitNotificationClient downUser1 downClient1 (Just newMsgNotif) isDeleteUserNotif objQid delUserDeletedNotif `shouldMatch` objQid delUser diff --git a/integration/test/Test/LegalHold.hs b/integration/test/Test/LegalHold.hs index e8cc0b22743..f049acbaf70 100644 --- a/integration/test/Test/LegalHold.hs +++ b/integration/test/Test/LegalHold.hs @@ -319,7 +319,7 @@ testLHRequestDevice v = do [bobc1, bobc2] <- replicateM 2 do objId $ addClient bob def `bindResponse` getJSON 201 for_ [bobc1, bobc2] \client -> - awaitNotification bob client noValue isUserLegalholdRequestNotif >>= \notif -> do + awaitNotificationClient bob client noValue isUserLegalholdRequestNotif >>= \notif -> do notif %. "payload.0.last_prekey" `shouldMatch` lpk notif %. "payload.0.id" `shouldMatch` objId bob @@ -411,15 +411,14 @@ testLHApproveDevice = do replicateM 2 do objId $ addClient bob def `bindResponse` getJSON 201 >>= traverse_ \client -> - awaitNotification bob client noValue isUserClientAddNotif >>= \notif -> do + awaitNotificationClient bob client noValue isUserClientAddNotif >>= \notif -> do notif %. "payload.0.client.type" `shouldMatch` "legalhold" notif %. "payload.0.client.class" `shouldMatch` "legalhold" -- the other team members receive a notification about the -- legalhold device being approved in their team for_ [alice, charlie] \user -> do - client <- objId $ addClient user def `bindResponse` getJSON 201 - awaitNotification user client noValue isUserLegalholdEnabledNotif >>= \notif -> do + awaitNotification user noValue isUserLegalholdEnabledNotif >>= \notif -> do notif %. "payload.0.id" `shouldMatch` objId bob for_ [ollie, sandy] \outsider -> do outsiderClient <- objId $ addClient outsider def `bindResponse` getJSON 201 @@ -489,9 +488,7 @@ testLHDisableForUser = do withMockServer def lhMockApp \lhDomAndPort chan -> do setUpLHDevice tid alice bob lhDomAndPort - bobc <- objId $ addClient bob def `bindResponse` getJSON 201 - - awaitNotification bob bobc noValue isUserClientAddNotif >>= \notif -> do + awaitNotification bob noValue isUserClientAddNotif >>= \notif -> do notif %. "payload.0.client.type" `shouldMatch` "legalhold" notif %. "payload.0.client.class" `shouldMatch` "legalhold" @@ -515,8 +512,8 @@ testLHDisableForUser = do mzero void $ local (setTimeoutTo 90) do - awaitNotification bob bobc noValue isUserClientRemoveNotif - *> awaitNotification bob bobc noValue isUserLegalholdDisabledNotif + awaitNotification bob noValue isUserClientRemoveNotif + *> awaitNotification bob noValue isUserLegalholdDisabledNotif bobId <- objId bob lhClients <- @@ -914,9 +911,9 @@ testBlockLHForMLSUsers = do -- scenario 1: -- if charlie is in any MLS conversation, he cannot approve to be put under legalhold (charlie, tid, []) <- createTeam OwnDomain 1 - [charlie1] <- traverse (createMLSClient def) [charlie] - void $ createNewGroup charlie1 - void $ createAddCommit charlie1 [charlie] >>= sendAndConsumeCommitBundle + [charlie1] <- traverse (createMLSClient def def) [charlie] + convId <- createNewGroup def charlie1 + void $ createAddCommit charlie1 convId [charlie] >>= sendAndConsumeCommitBundle legalholdWhitelistTeam tid charlie >>= assertStatus 200 withMockServer def lhMockApp \lhDomAndPort _chan -> do @@ -934,9 +931,9 @@ testBlockLHForMLSUsers = do testBlockClaimingKeyPackageForLHUsers :: (HasCallStack) => App () testBlockClaimingKeyPackageForLHUsers = do (alice, tid, [charlie]) <- createTeam OwnDomain 2 - [alice1, charlie1] <- traverse (createMLSClient def) [alice, charlie] - _ <- uploadNewKeyPackage charlie1 - _ <- createNewGroup alice1 + [alice1, charlie1] <- traverse (createMLSClient def def) [alice, charlie] + _ <- uploadNewKeyPackage def charlie1 + _ <- createNewGroup def alice1 legalholdWhitelistTeam tid alice >>= assertStatus 200 withMockServer def lhMockApp \lhDomAndPort _chan -> do postLegalHoldSettings tid alice (mkLegalHoldSettings lhDomAndPort) >>= assertStatus 201 @@ -946,8 +943,7 @@ testBlockClaimingKeyPackageForLHUsers = do pStatus <- profile %. "legalhold_status" & asString pStatus `shouldMatch` "enabled" - mls <- getMLSState - claimKeyPackages mls.ciphersuite alice1 charlie + claimKeyPackages def alice1 charlie `bindResponse` assertLabel 409 "mls-legal-hold-not-allowed" -- | scenario 2.2: @@ -958,8 +954,8 @@ testBlockClaimingKeyPackageForLHUsers = do testBlockCreateMLSConvForLHUsers :: (HasCallStack) => LhApiVersion -> App () testBlockCreateMLSConvForLHUsers v = do (alice, tid, [charlie]) <- createTeam OwnDomain 2 - [alice1, charlie1] <- traverse (createMLSClient def) [alice, charlie] - _ <- uploadNewKeyPackage alice1 + [alice1, charlie1] <- traverse (createMLSClient def def) [alice, charlie] + _ <- uploadNewKeyPackage def alice1 legalholdWhitelistTeam tid alice >>= assertStatus 200 withMockServer def (lhMockAppV v) \lhDomAndPort _chan -> do postLegalHoldSettings tid alice (mkLegalHoldSettings lhDomAndPort) >>= assertStatus 201 @@ -970,12 +966,12 @@ testBlockCreateMLSConvForLHUsers v = do pStatus `shouldMatch` "enabled" -- charlie tries to create a group and should fail when POSTing the add commit - _ <- createNewGroup charlie1 + convId <- createNewGroup def charlie1 void -- we try to add alice since adding charlie himself would trigger 2.1 -- since he'd try to claim his own keypackages - $ createAddCommit charlie1 [alice] + $ createAddCommit charlie1 convId [alice] >>= \mp -> postMLSCommitBundle mp.sender (mkBundle mp) `bindResponse` assertLabel 409 "mls-legal-hold-not-allowed" @@ -983,12 +979,12 @@ testBlockCreateMLSConvForLHUsers v = do -- (unsurprisingly) this same thing should also work in the one2one case respJson <- getMLSOne2OneConversation alice charlie >>= getJSON 200 - resetGroup alice1 (respJson %. "conversation") + createGroup def alice1 =<< objConvId (respJson %. "conversation") void -- we try to add alice since adding charlie himself would trigger 2.1 -- since he'd try to claim his own keypackages - $ createAddCommit charlie1 [alice] + $ createAddCommit charlie1 convId [alice] >>= \mp -> postMLSCommitBundle mp.sender (mkBundle mp) `bindResponse` assertLabel 409 "mls-legal-hold-not-allowed" diff --git a/integration/test/Test/MLS.hs b/integration/test/Test/MLS.hs index f721f9ad06b..856f480e983 100644 --- a/integration/test/Test/MLS.hs +++ b/integration/test/Test/MLS.hs @@ -6,6 +6,7 @@ import API.Brig (claimKeyPackages, deleteClient) import API.Galley import qualified Data.ByteString.Base64 as Base64 import qualified Data.ByteString.Char8 as B8 +import qualified Data.Map as Map import qualified Data.Set as Set import qualified Data.Text as T import qualified Data.Text.Encoding as T @@ -19,15 +20,15 @@ import Testlib.Prelude testSendMessageNoReturnToSender :: (HasCallStack) => App () testSendMessageNoReturnToSender = do [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] - [alice1, alice2, bob1, bob2] <- traverse (createMLSClient def) [alice, alice, bob, bob] - traverse_ uploadNewKeyPackage [alice2, bob1, bob2] - void $ createNewGroup alice1 - void $ createAddCommit alice1 [alice, bob] >>= sendAndConsumeCommitBundle + [alice1, alice2, bob1, bob2] <- traverse (createMLSClient def def) [alice, alice, bob, bob] + traverse_ (uploadNewKeyPackage def) [alice2, bob1, bob2] + convId <- createNewGroup def alice1 + void $ createAddCommit alice1 convId [alice, bob] >>= sendAndConsumeCommitBundle -- alice1 sends a message to the conversation, all clients but alice1 receive -- the message withWebSockets [alice1, alice2, bob1, bob2] $ \(wsSender : wss) -> do - mp <- createApplicationMessage alice1 "hello, bob" + mp <- createApplicationMessage convId alice1 "hello, bob" bindResponse (postMLSMessage mp.sender mp.message) $ \resp -> do resp.status `shouldMatchInt` 201 for_ wss $ \ws -> do @@ -47,25 +48,25 @@ testPastStaleApplicationMessage :: (HasCallStack) => Domain -> App () testPastStaleApplicationMessage otherDomain = do [alice, bob, charlie, dave, eve] <- createAndConnectUsers [OwnDomain, otherDomain, OwnDomain, OwnDomain, OwnDomain] - [alice1, bob1, charlie1] <- traverse (createMLSClient def) [alice, bob, charlie] - traverse_ uploadNewKeyPackage [bob1, charlie1] - void $ createNewGroup alice1 + [alice1, bob1, charlie1] <- traverse (createMLSClient def def) [alice, bob, charlie] + traverse_ (uploadNewKeyPackage def) [bob1, charlie1] + convId <- createNewGroup def alice1 -- alice adds bob first - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle -- bob prepares some application messages - [msg1, msg2] <- replicateM 2 $ createApplicationMessage bob1 "hi alice" + [msg1, msg2] <- replicateM 2 $ createApplicationMessage convId bob1 "hi alice" -- alice adds charlie and dave with different commits - void $ createAddCommit alice1 [charlie] >>= sendAndConsumeCommitBundle - void $ createAddCommit alice1 [dave] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [charlie] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [dave] >>= sendAndConsumeCommitBundle -- bob's application messages still go through void $ postMLSMessage bob1 msg1.message >>= getJSON 201 -- alice adds eve - void $ createAddCommit alice1 [eve] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [eve] >>= sendAndConsumeCommitBundle -- bob's application messages are now rejected void $ postMLSMessage bob1 msg2.message >>= getJSON 409 @@ -73,20 +74,28 @@ testPastStaleApplicationMessage otherDomain = do testFutureStaleApplicationMessage :: (HasCallStack) => App () testFutureStaleApplicationMessage = do [alice, bob, charlie] <- createAndConnectUsers [OwnDomain, OwnDomain, OwnDomain] - [alice1, bob1, charlie1] <- traverse (createMLSClient def) [alice, bob, charlie] - traverse_ uploadNewKeyPackage [bob1, charlie1] - void $ createNewGroup alice1 + [alice1, bob1, charlie1] <- traverse (createMLSClient def def) [alice, bob, charlie] + traverse_ (uploadNewKeyPackage def) [bob1, charlie1] + convId <- createNewGroup def alice1 -- alice adds bob - void . sendAndConsumeCommitBundle =<< createAddCommit alice1 [bob] + void . sendAndConsumeCommitBundle =<< createAddCommit alice1 convId [bob] -- alice adds charlie and consumes the commit without sending it - void $ createAddCommit alice1 [charlie] + void $ createAddCommit alice1 convId [charlie] modifyMLSState $ \mls -> mls - { epoch = epoch mls + 1, - members = members mls <> Set.singleton charlie1, - newMembers = mempty + { convs = + Map.adjust + ( \conv -> + conv + { epoch = conv.epoch + 1, + members = Set.insert charlie1 conv.members, + newMembers = mempty + } + ) + convId + mls.convs } -- alice's application message is rejected @@ -94,7 +103,7 @@ testFutureStaleApplicationMessage = do . getJSON 409 =<< postMLSMessage alice1 . (.message) - =<< createApplicationMessage alice1 "hi bob" + =<< createApplicationMessage convId alice1 "hi bob" testMixedProtocolUpgrade :: (HasCallStack) => Domain -> App () testMixedProtocolUpgrade secondDomain = do @@ -102,7 +111,7 @@ testMixedProtocolUpgrade secondDomain = do [bob, charlie] <- replicateM 2 (randomUser secondDomain def) connectUsers [alice, bob, charlie] - qcnv <- + convId <- postConversation alice defProteus @@ -110,77 +119,79 @@ testMixedProtocolUpgrade secondDomain = do team = Just tid } >>= getJSON 201 + >>= objConvId - bindResponse (putConversationProtocol bob qcnv "mls") $ \resp -> do + bindResponse (putConversationProtocol bob convId "mls") $ \resp -> do resp.status `shouldMatchInt` 403 withWebSockets [alice, charlie] $ \websockets -> do - bindResponse (putConversationProtocol bob qcnv "mixed") $ \resp -> do + bindResponse (putConversationProtocol bob convId "mixed") $ \resp -> do resp.status `shouldMatchInt` 200 - resp.json %. "conversation" `shouldMatch` (qcnv %. "id") + resp.json %. "qualified_conversation" `shouldMatch` (convIdToQidObject convId) resp.json %. "data.protocol" `shouldMatch` "mixed" - modifyMLSState $ \mls -> mls {protocol = MLSProtocolMixed} for_ websockets $ \ws -> do n <- awaitMatch (\value -> nPayload value %. "type" `isEqual` "conversation.protocol-update") ws nPayload n %. "data.protocol" `shouldMatch` "mixed" - bindResponse (getConversation alice qcnv) $ \resp -> do + bindResponse (getConversation alice (convIdToQidObject convId)) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "protocol" `shouldMatch` "mixed" resp.json %. "epoch" `shouldMatchInt` 0 - bindResponse (putConversationProtocol alice qcnv "mixed") $ \resp -> do + bindResponse (putConversationProtocol alice convId "mixed") $ \resp -> do resp.status `shouldMatchInt` 204 - bindResponse (putConversationProtocol bob qcnv "proteus") $ \resp -> do + bindResponse (putConversationProtocol bob convId "proteus") $ \resp -> do resp.status `shouldMatchInt` 403 - bindResponse (putConversationProtocol bob qcnv "invalid") $ \resp -> do + bindResponse (putConversationProtocol bob convId "invalid") $ \resp -> do resp.status `shouldMatchInt` 400 testMixedProtocolNonTeam :: (HasCallStack) => Domain -> App () testMixedProtocolNonTeam secondDomain = do [alice, bob] <- createAndConnectUsers [OwnDomain, secondDomain] - qcnv <- + convId <- postConversation alice defProteus {qualifiedUsers = [bob]} >>= getJSON 201 + >>= objConvId - bindResponse (putConversationProtocol bob qcnv "mixed") $ \resp -> do + bindResponse (putConversationProtocol bob convId "mixed") $ \resp -> do resp.status `shouldMatchInt` 403 testMixedProtocolAddUsers :: (HasCallStack) => Domain -> Ciphersuite -> App () testMixedProtocolAddUsers secondDomain suite = do - setMLSCiphersuite suite (alice, tid, _) <- createTeam OwnDomain 1 [bob, charlie] <- replicateM 2 (randomUser secondDomain def) connectUsers [alice, bob, charlie] - qcnv <- - postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} - >>= getJSON 201 + convId <- do + convId <- + postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} + >>= getJSON 201 + >>= objConvId - bindResponse (putConversationProtocol bob qcnv "mixed") $ \resp -> do - resp.status `shouldMatchInt` 200 - modifyMLSState $ \mls -> mls {protocol = MLSProtocolMixed} + bindResponse (putConversationProtocol bob convId "mixed") $ \resp -> do + resp.status `shouldMatchInt` 200 - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] + bindResponse (getConversation alice (convIdToQidObject convId)) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "epoch" `shouldMatchInt` 0 + objConvId resp.json - bindResponse (getConversation alice qcnv) $ \resp -> do - resp.status `shouldMatchInt` 200 - resp.json %. "epoch" `shouldMatchInt` 0 - createGroup alice1 resp.json + [alice1, bob1] <- traverse (createMLSClient suite def) [alice, bob] + createGroup suite alice1 convId - traverse_ uploadNewKeyPackage [bob1] + void $ uploadNewKeyPackage suite bob1 withWebSocket bob $ \ws -> do - mp <- createAddCommit alice1 [bob] + mp <- createAddCommit alice1 convId [bob] welcome <- assertJust "should have welcome" mp.welcome - void $ sendAndConsumeCommitBundle mp + void $ sendAndConsumeCommitBundleWithProtocol MLSProtocolMixed mp n <- awaitMatch (\n -> nPayload n %. "type" `isEqual` "conversation.mls-welcome") ws nPayload n %. "data" `shouldMatch` T.decodeUtf8 (Base64.encode welcome) - bindResponse (getConversation alice qcnv) $ \resp -> do + bindResponse (getConversation alice (convIdToQidObject convId)) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "epoch" `shouldMatchInt` 1 (suiteCode, _) <- assertOne $ T.hexadecimal (T.pack suite.code) @@ -192,32 +203,34 @@ testMixedProtocolUserLeaves secondDomain = do bob <- randomUser secondDomain def connectUsers [alice, bob] - qcnv <- - postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} - >>= getJSON 201 + convId <- do + convId <- + postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} + >>= getJSON 201 + >>= objConvId - bindResponse (putConversationProtocol bob qcnv "mixed") $ \resp -> do - resp.status `shouldMatchInt` 200 - modifyMLSState $ \mls -> mls {protocol = MLSProtocolMixed} - - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] + bindResponse (putConversationProtocol bob convId "mixed") $ \resp -> do + resp.status `shouldMatchInt` 200 - bindResponse (getConversation alice qcnv) $ \resp -> do - resp.status `shouldMatchInt` 200 - createGroup alice1 resp.json + bindResponse (getConversation alice (convIdToQidObject convId)) $ \resp -> do + resp.status `shouldMatchInt` 200 + objConvId resp.json - traverse_ uploadNewKeyPackage [bob1] + [alice1, bob1] <- traverse (createMLSClient def def) [alice, bob] + createGroup def alice1 convId + void $ uploadNewKeyPackage def bob1 - mp <- createAddCommit alice1 [bob] - void $ sendAndConsumeCommitBundle mp + mp <- createAddCommit alice1 convId [bob] + void $ sendAndConsumeCommitBundleWithProtocol MLSProtocolMixed mp withWebSocket alice $ \ws -> do - bindResponse (removeConversationMember bob qcnv) $ \resp -> + bindResponse (removeConversationMember bob (convIdToQidObject convId)) $ \resp -> resp.status `shouldMatchInt` 200 n <- awaitMatch (\n -> nPayload n %. "type" `isEqual` "conversation.mls-message-add") ws - msg <- asByteString (nPayload n %. "data") >>= showMessage alice1 + conv <- getMLSConv convId + msg <- asByteString (nPayload n %. "data") >>= showMessage conv.ciphersuite alice1 let leafIndexBob = 1 msg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leafIndexBob msg %. "message.content.sender.External" `shouldMatchInt` 0 @@ -228,29 +241,31 @@ testMixedProtocolAddPartialClients secondDomain = do bob <- randomUser secondDomain def connectUsers [alice, bob] - qcnv <- - postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} - >>= getJSON 201 + convId <- do + convId <- + postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} + >>= getJSON 201 + >>= objConvId - bindResponse (putConversationProtocol bob qcnv "mixed") $ \resp -> do - resp.status `shouldMatchInt` 200 - modifyMLSState $ \mls -> mls {protocol = MLSProtocolMixed} + bindResponse (putConversationProtocol bob convId "mixed") $ \resp -> do + resp.status `shouldMatchInt` 200 - [alice1, bob1, bob2] <- traverse (createMLSClient def) [alice, bob, bob] + bindResponse (getConversation alice (convIdToQidObject convId)) $ \resp -> do + resp.status `shouldMatchInt` 200 + objConvId resp.json - bindResponse (getConversation alice qcnv) $ \resp -> do - resp.status `shouldMatchInt` 200 - createGroup alice1 resp.json + [alice1, bob1, bob2] <- traverse (createMLSClient def def) [alice, bob, bob] + createGroup def alice1 convId - traverse_ uploadNewKeyPackage [bob1, bob1, bob2, bob2] + traverse_ (uploadNewKeyPackage def) [bob1, bob1, bob2, bob2] -- create add commit for only one of bob's two clients do bundle <- claimKeyPackages def alice1 bob >>= getJSON 200 kps <- unbundleKeyPackages bundle kp1 <- assertOne (filter ((== bob1) . fst) kps) - mp <- createAddCommitWithKeyPackages alice1 [kp1] - void $ sendAndConsumeCommitBundle mp + mp <- createAddCommitWithKeyPackages alice1 convId [kp1] + void $ sendAndConsumeCommitBundleWithProtocol MLSProtocolMixed mp -- this tests that bob's backend has a mapping of group id to the remote conv -- this test is only interesting when bob is on OtherDomain @@ -258,7 +273,7 @@ testMixedProtocolAddPartialClients secondDomain = do bundle <- claimKeyPackages def bob1 bob >>= getJSON 200 kps <- unbundleKeyPackages bundle kp2 <- assertOne (filter ((== bob2) . fst) kps) - mp <- createAddCommitWithKeyPackages bob1 [kp2] + mp <- createAddCommitWithKeyPackages bob1 convId [kp2] void $ postMLSCommitBundle mp.sender (mkBundle mp) >>= getJSON 201 testMixedProtocolRemovePartialClients :: (HasCallStack) => Domain -> App () @@ -267,23 +282,24 @@ testMixedProtocolRemovePartialClients secondDomain = do bob <- randomUser secondDomain def connectUsers [alice, bob] - qcnv <- - postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} - >>= getJSON 201 + convId <- do + convId <- + postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} + >>= getJSON 201 + >>= objConvId - bindResponse (putConversationProtocol bob qcnv "mixed") $ \resp -> do - resp.status `shouldMatchInt` 200 - modifyMLSState $ \mls -> mls {protocol = MLSProtocolMixed} - - [alice1, bob1, bob2] <- traverse (createMLSClient def) [alice, bob, bob] + bindResponse (putConversationProtocol bob convId "mixed") $ \resp -> do + resp.status `shouldMatchInt` 200 - bindResponse (getConversation alice qcnv) $ \resp -> do - resp.status `shouldMatchInt` 200 - createGroup alice1 resp.json + bindResponse (getConversation alice (convIdToQidObject convId)) $ \resp -> do + resp.status `shouldMatchInt` 200 + objConvId resp.json - traverse_ uploadNewKeyPackage [bob1, bob2] - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle - mp <- createRemoveCommit alice1 [bob1] + [alice1, bob1, bob2] <- traverse (createMLSClient def def) [alice, bob, bob] + createGroup def alice1 convId + traverse_ (uploadNewKeyPackage def) [bob1, bob2] + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundleWithProtocol MLSProtocolMixed + mp <- createRemoveCommit alice1 convId [bob1] void $ postMLSCommitBundle mp.sender (mkBundle mp) >>= getJSON 201 @@ -293,83 +309,83 @@ testMixedProtocolAppMessagesAreDenied secondDomain = do bob <- randomUser secondDomain def connectUsers [alice, bob] - qcnv <- - postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} - >>= getJSON 201 + convId <- do + convId <- + postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} + >>= getJSON 201 + >>= objConvId - bindResponse (putConversationProtocol bob qcnv "mixed") $ \resp -> do - resp.status `shouldMatchInt` 200 - modifyMLSState $ \mls -> mls {protocol = MLSProtocolMixed} + bindResponse (putConversationProtocol bob convId "mixed") $ \resp -> do + resp.status `shouldMatchInt` 200 - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] + bindResponse (getConversation alice (convIdToQidObject convId)) $ \resp -> do + resp.status `shouldMatchInt` 200 + objConvId resp.json - traverse_ uploadNewKeyPackage [bob1] + [alice1, bob1] <- traverse (createMLSClient def def) [alice, bob] - bindResponse (getConversation alice qcnv) $ \resp -> do - resp.status `shouldMatchInt` 200 - createGroup alice1 resp.json + createGroup def alice1 convId + void $ uploadNewKeyPackage def bob1 - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundleWithProtocol MLSProtocolMixed - mp <- createApplicationMessage bob1 "hello, world" + mp <- createApplicationMessage convId bob1 "hello, world" bindResponse (postMLSMessage mp.sender mp.message) $ \resp -> do resp.status `shouldMatchInt` 422 resp.json %. "label" `shouldMatch` "mls-unsupported-message" testMLSProtocolUpgrade :: (HasCallStack) => Domain -> App () testMLSProtocolUpgrade secondDomain = do - (alice, bob, conv) <- simpleMixedConversationSetup secondDomain + (alice, bob, convId) <- simpleMixedConversationSetup secondDomain charlie <- randomUser OwnDomain def -- alice creates MLS group and bob joins - [alice1, bob1, charlie1] <- traverse (createMLSClient def) [alice, bob, charlie] - createGroup alice1 conv - void $ createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle - void $ createExternalCommit bob1 Nothing >>= sendAndConsumeCommitBundle + [alice1, bob1, charlie1] <- traverse (createMLSClient def def) [alice, bob, charlie] + createGroup def alice1 convId + void $ createPendingProposalCommit convId alice1 >>= sendAndConsumeCommitBundleWithProtocol MLSProtocolMixed + void $ createExternalCommit convId bob1 Nothing >>= sendAndConsumeCommitBundleWithProtocol MLSProtocolMixed void $ withWebSocket bob $ \ws -> do -- charlie is added to the group - void $ uploadNewKeyPackage charlie1 - void $ createAddCommit alice1 [charlie] >>= sendAndConsumeCommitBundle + void $ uploadNewKeyPackage def charlie1 + void $ createAddCommit alice1 convId [charlie] >>= sendAndConsumeCommitBundleWithProtocol MLSProtocolMixed awaitMatch isNewMLSMessageNotif ws supportMLS alice - bindResponse (putConversationProtocol bob conv "mls") $ \resp -> do + bindResponse (putConversationProtocol bob convId "mls") $ \resp -> do resp.status `shouldMatchInt` 400 resp.json %. "label" `shouldMatch` "mls-migration-criteria-not-satisfied" - bindResponse (getConversation alice conv) $ \resp -> do + bindResponse (getConversation alice (convIdToQidObject convId)) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "protocol" `shouldMatch` "mixed" supportMLS bob withWebSockets [alice1, bob1] $ \wss -> do - bindResponse (putConversationProtocol bob conv "mls") $ \resp -> do + bindResponse (putConversationProtocol bob convId "mls") $ \resp -> do resp.status `shouldMatchInt` 200 - modifyMLSState $ \mls -> mls {protocol = MLSProtocolMLS} for_ wss $ \ws -> do n <- awaitMatch isNewMLSMessageNotif ws - msg <- asByteString (nPayload n %. "data") >>= showMessage alice1 + msg <- asByteString (nPayload n %. "data") >>= showMessage def alice1 let leafIndexCharlie = 2 msg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leafIndexCharlie msg %. "message.content.sender.External" `shouldMatchInt` 0 - bindResponse (getConversation alice conv) $ \resp -> do + bindResponse (getConversation alice (convIdToQidObject convId)) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "protocol" `shouldMatch` "mls" testAddUserSimple :: (HasCallStack) => Ciphersuite -> CredentialType -> App () testAddUserSimple suite ctype = do - setMLSCiphersuite suite [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] - bob1 <- createMLSClient def {credType = ctype} bob - void $ uploadNewKeyPackage bob1 - [alice1, bob2] <- traverse (createMLSClient def {credType = ctype}) [alice, bob] + bob1 <- createMLSClient suite def {credType = ctype} bob + void $ uploadNewKeyPackage suite bob1 + [alice1, bob2] <- traverse (createMLSClient suite def {credType = ctype}) [alice, bob] - traverse_ uploadNewKeyPackage [bob2] + void $ uploadNewKeyPackage suite bob2 qcnv <- withWebSocket alice $ \ws -> do - (_, qcnv) <- createNewGroup alice1 + qcnv <- createNewGroup suite alice1 -- check that the conversation inside the ConvCreated event contains -- epoch and ciphersuite, regardless of the API version n <- awaitMatch isConvCreateNotif ws @@ -377,11 +393,12 @@ testAddUserSimple suite ctype = do n %. "payload.0.data.cipher_suite" `shouldMatchInt` 1 pure qcnv - resp <- createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + resp <- createAddCommit alice1 qcnv [bob] >>= sendAndConsumeCommitBundle events <- resp %. "events" & asList do event <- assertOne events - shouldMatch (event %. "qualified_conversation") qcnv + shouldMatch (event %. "qualified_conversation.id") qcnv.id_ + shouldMatch (event %. "qualified_conversation.domain") qcnv.domain shouldMatch (event %. "type") "conversation.member-join" shouldMatch (event %. "from") (objId alice) members <- event %. "data" %. "users" & asList @@ -391,7 +408,7 @@ testAddUserSimple suite ctype = do -- check that bob can now see the conversation convs <- getAllConvs bob - convIds <- traverse (%. "qualified_id") convs + convIds <- traverse objConvId convs void $ assertBool "Users added to an MLS group should find it when listing conversations" @@ -400,14 +417,14 @@ testAddUserSimple suite ctype = do testRemoteAddUser :: (HasCallStack) => App () testRemoteAddUser = do [alice, bob, charlie] <- createAndConnectUsers [OwnDomain, OtherDomain, OwnDomain] - [alice1, bob1, charlie1] <- traverse (createMLSClient def) [alice, bob, charlie] - traverse_ uploadNewKeyPackage [bob1, charlie1] - (_, conv) <- createNewGroup alice1 - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle - bindResponse (updateConversationMember alice1 conv bob "wire_admin") $ \resp -> + [alice1, bob1, charlie1] <- traverse (createMLSClient def def) [alice, bob, charlie] + traverse_ (uploadNewKeyPackage def) [bob1, charlie1] + conv <- createNewGroup def alice1 + void $ createAddCommit alice1 conv [bob] >>= sendAndConsumeCommitBundle + bindResponse (updateConversationMember alice1 (convIdToQidObject conv) bob "wire_admin") $ \resp -> resp.status `shouldMatchInt` 200 - mp <- createAddCommit bob1 [charlie] + mp <- createAddCommit bob1 conv [charlie] -- Support for remote admins is not implemeted yet, but this shows that add -- proposal is being applied action bindResponse (postMLSCommitBundle mp.sender (mkBundle mp)) $ \resp -> do @@ -416,115 +433,113 @@ testRemoteAddUser = do testRemoteRemoveClient :: (HasCallStack) => Ciphersuite -> App () testRemoteRemoveClient suite = do - setMLSCiphersuite suite [alice, bob] <- createAndConnectUsers [OwnDomain, OtherDomain] - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - void $ uploadNewKeyPackage bob1 - (_, conv) <- createNewGroup alice1 - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + [alice1, bob1] <- traverse (createMLSClient suite def) [alice, bob] + void $ uploadNewKeyPackage suite bob1 + conv <- createNewGroup suite alice1 + void $ createAddCommit alice1 conv [bob] >>= sendAndConsumeCommitBundle withWebSocket alice $ \wsAlice -> do void $ deleteClient bob bob1.client >>= getBody 200 let predicate n = nPayload n %. "type" `isEqual` "conversation.mls-message-add" n <- awaitMatch predicate wsAlice - shouldMatch (nPayload n %. "conversation") (objId conv) + shouldMatch (nPayload n %. "qualified_conversation") (convIdToQidObject conv) shouldMatch (nPayload n %. "from") (objId bob) mlsMsg <- asByteString (nPayload n %. "data") -- Checks that the remove proposal is consumable by alice - void $ mlsCliConsume alice1 mlsMsg + void $ mlsCliConsume conv suite alice1 mlsMsg -- This doesn't work because `sendAndConsumeCommitBundle` doesn't like -- remove proposals from the backend. We should fix that in future. -- void $ createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle - parsedMsg <- showMessage alice1 mlsMsg + parsedMsg <- showMessage suite alice1 mlsMsg let leafIndexBob = 1 parsedMsg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leafIndexBob parsedMsg %. "message.content.sender.External" `shouldMatchInt` 0 testRemoteRemoveCreatorClient :: (HasCallStack) => Ciphersuite -> App () testRemoteRemoveCreatorClient suite = do - setMLSCiphersuite suite [alice, bob] <- createAndConnectUsers [OwnDomain, OtherDomain] - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - void $ uploadNewKeyPackage bob1 - (_, conv) <- createNewGroup alice1 - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + [alice1, bob1] <- traverse (createMLSClient suite def) [alice, bob] + void $ uploadNewKeyPackage suite bob1 + conv <- createNewGroup suite alice1 + void $ createAddCommit alice1 conv [bob] >>= sendAndConsumeCommitBundle withWebSocket bob $ \wsBob -> do void $ deleteClient alice alice1.client >>= getBody 200 let predicate n = nPayload n %. "type" `isEqual` "conversation.mls-message-add" n <- awaitMatch predicate wsBob - shouldMatch (nPayload n %. "conversation") (objId conv) + shouldMatch (nPayload n %. "qualified_conversation") (convIdToQidObject conv) shouldMatch (nPayload n %. "from") (objId alice) mlsMsg <- asByteString (nPayload n %. "data") -- Checks that the remove proposal is consumable by alice - void $ mlsCliConsume alice1 mlsMsg + void $ mlsCliConsume conv suite alice1 mlsMsg -- This doesn't work because `sendAndConsumeCommitBundle` doesn't like -- remove proposals from the backend. We should fix that in future. -- void $ createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle - parsedMsg <- showMessage alice1 mlsMsg + parsedMsg <- showMessage suite alice1 mlsMsg let leafIndexAlice = 0 parsedMsg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leafIndexAlice parsedMsg %. "message.content.sender.External" `shouldMatchInt` 0 testCreateSubConv :: (HasCallStack) => Ciphersuite -> App () testCreateSubConv suite = do - setMLSCiphersuite suite [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] - aliceClients@(alice1 : _) <- replicateM 5 $ createMLSClient def alice - replicateM_ 3 $ traverse_ uploadNewKeyPackage aliceClients - [bob1, bob2] <- replicateM 2 $ createMLSClient def bob - replicateM_ 3 $ traverse_ uploadNewKeyPackage [bob1, bob2] - void $ createNewGroup alice1 - void $ createAddCommit alice1 [alice, bob] >>= sendAndConsumeCommitBundle - createSubConv alice1 "conference" + aliceClients@(alice1 : _) <- replicateM 5 $ createMLSClient suite def alice + replicateM_ 3 $ traverse_ (uploadNewKeyPackage suite) aliceClients + [bob1, bob2] <- replicateM 2 $ createMLSClient suite def bob + replicateM_ 3 $ traverse_ (uploadNewKeyPackage suite) [bob1, bob2] + convId <- createNewGroup suite alice1 + void $ createAddCommit alice1 convId [alice, bob] >>= sendAndConsumeCommitBundle + createSubConv suite convId alice1 "conference" testCreateSubConvProteus :: App () testCreateSubConvProteus = do alice <- randomUser OwnDomain def conv <- bindResponse (postConversation alice defProteus) $ \resp -> do resp.status `shouldMatchInt` 201 - resp.json + objConvId resp.json bindResponse (getSubConversation alice conv "conference") $ \resp -> resp.status `shouldMatchInt` 404 testSelfConversation :: Version5 -> App () testSelfConversation v = withVersion5 v $ do alice <- randomUser OwnDomain def - creator : others <- traverse (createMLSClient def) (replicate 3 alice) - traverse_ uploadNewKeyPackage others - (_, conv) <- createSelfGroup creator + creator : others <- traverse (createMLSClient def def) (replicate 3 alice) + traverse_ (uploadNewKeyPackage def) others + (_, conv) <- createSelfGroup def creator + convId <- objConvId conv conv %. "epoch" `shouldMatchInt` 0 case v of Version5 -> conv %. "cipher_suite" `shouldMatchInt` 1 NoVersion5 -> assertFieldMissing conv "cipher_suite" - void $ createAddCommit creator [alice] >>= sendAndConsumeCommitBundle + void $ createAddCommit creator convId [alice] >>= sendAndConsumeCommitBundle - newClient <- createMLSClient def alice - void $ uploadNewKeyPackage newClient - void $ createExternalCommit newClient Nothing >>= sendAndConsumeCommitBundle + newClient <- createMLSClient def def alice + void $ uploadNewKeyPackage def newClient + void $ createExternalCommit convId newClient Nothing >>= sendAndConsumeCommitBundle -- | FUTUREWORK: Don't allow partial adds, not even in the first commit testFirstCommitAllowsPartialAdds :: (HasCallStack) => App () testFirstCommitAllowsPartialAdds = do alice <- randomUser OwnDomain def - [alice1, alice2, alice3] <- traverse (createMLSClient def) [alice, alice, alice] - traverse_ uploadNewKeyPackage [alice1, alice2, alice2, alice3, alice3] + [alice1, alice2, alice3] <- traverse (createMLSClient def def) [alice, alice, alice] + traverse_ (uploadNewKeyPackage def) [alice1, alice2, alice2, alice3, alice3] - (_, _qcnv) <- createNewGroup alice1 + convId <- createNewGroup def alice1 bundle <- claimKeyPackages def alice1 alice >>= getJSON 200 kps <- unbundleKeyPackages bundle -- first commit only adds kp for alice2 (not alice2 and alice3) - mp <- createAddCommitWithKeyPackages alice1 (filter ((== alice2) . fst) kps) + mp <- createAddCommitWithKeyPackages alice1 convId (filter ((== alice2) . fst) kps) bindResponse (postMLSCommitBundle mp.sender (mkBundle mp)) $ \resp -> do resp.status `shouldMatchInt` 409 resp.json %. "label" `shouldMatch` "mls-client-mismatch" @@ -538,24 +553,24 @@ testAddUserPartial = do [alice, bob, charlie] <- createAndConnectUsers (replicate 3 OwnDomain) -- Bob has 3 clients, Charlie has 2 - alice1 <- createMLSClient def alice - bobClients@[_bob1, _bob2, bob3] <- replicateM 3 (createMLSClient def bob) - charlieClients <- replicateM 2 (createMLSClient def charlie) + alice1 <- createMLSClient def def alice + bobClients@[_bob1, _bob2, bob3] <- replicateM 3 (createMLSClient def def bob) + charlieClients <- replicateM 2 (createMLSClient def def charlie) -- Only the first 2 clients of Bob's have uploaded key packages - traverse_ uploadNewKeyPackage (take 2 bobClients <> charlieClients) + traverse_ (uploadNewKeyPackage def) (take 2 bobClients <> charlieClients) -- alice adds bob's first 2 clients - void $ createNewGroup alice1 + convId <- createNewGroup def alice1 -- alice sends a commit now, and should get a conflict error kps <- fmap concat . for [bob, charlie] $ \user -> do bundle <- claimKeyPackages def alice1 user >>= getJSON 200 unbundleKeyPackages bundle - mp <- createAddCommitWithKeyPackages alice1 kps + mp <- createAddCommitWithKeyPackages alice1 convId kps -- before alice can commit, bob3 uploads a key package - void $ uploadNewKeyPackage bob3 + void $ uploadNewKeyPackage def bob3 err <- postMLSCommitBundle mp.sender (mkBundle mp) >>= getJSON 409 err %. "label" `shouldMatch` "mls-client-mismatch" @@ -567,30 +582,30 @@ testRemoveClientsIncomplete :: (HasCallStack) => App () testRemoveClientsIncomplete = do [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] - [alice1, bob1, bob2] <- traverse (createMLSClient def) [alice, bob, bob] - traverse_ uploadNewKeyPackage [bob1, bob2] - void $ createNewGroup alice1 - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle - mp <- createRemoveCommit alice1 [bob1] + [alice1, bob1, bob2] <- traverse (createMLSClient def def) [alice, bob, bob] + traverse_ (uploadNewKeyPackage def) [bob1, bob2] + convId <- createNewGroup def alice1 + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle + mp <- createRemoveCommit alice1 convId [bob1] err <- postMLSCommitBundle mp.sender (mkBundle mp) >>= getJSON 409 err %. "label" `shouldMatch` "mls-client-mismatch" testAdminRemovesUserFromConv :: (HasCallStack) => Ciphersuite -> App () testAdminRemovesUserFromConv suite = do - setMLSCiphersuite suite [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] - [alice1, bob1, bob2] <- traverse (createMLSClient def) [alice, bob, bob] + [alice1, bob1, bob2] <- traverse (createMLSClient suite def) [alice, bob, bob] - void $ createWireClient bob - traverse_ uploadNewKeyPackage [bob1, bob2] - (gid, qcnv) <- createNewGroup alice1 - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle - events <- createRemoveCommit alice1 [bob1, bob2] >>= sendAndConsumeCommitBundle + void $ createWireClient bob def + traverse_ (uploadNewKeyPackage suite) [bob1, bob2] + convId <- createNewGroup suite alice1 + let Just gid = convId.groupId + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle + events <- createRemoveCommit alice1 convId [bob1, bob2] >>= sendAndConsumeCommitBundle do event <- assertOne =<< asList (events %. "events") - event %. "qualified_conversation" `shouldMatch` qcnv + event %. "qualified_conversation" `shouldMatch` convIdToQidObject convId event %. "type" `shouldMatch` "conversation.member-leave" event %. "from" `shouldMatch` objId alice members <- event %. "data" %. "qualified_user_ids" & asList @@ -599,26 +614,26 @@ testAdminRemovesUserFromConv suite = do do convs <- getAllConvs bob - convIds <- traverse (%. "qualified_id") convs + convIds <- traverse objConvId convs clients <- bindResponse (getGroupClients alice gid) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "client_ids" & asList void $ assertOne clients assertBool "bob is not longer part of conversation after the commit" - (qcnv `notElem` convIds) + (convId `notElem` convIds) testLocalWelcome :: (HasCallStack) => App () testLocalWelcome = do users@[alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] - [alice1, bob1] <- traverse (createMLSClient def) users + [alice1, bob1] <- traverse (createMLSClient def def) users - void $ uploadNewKeyPackage bob1 + void $ uploadNewKeyPackage def bob1 - (_, qcnv) <- createNewGroup alice1 + convId <- createNewGroup def alice1 - commit <- createAddCommit alice1 [bob] + commit <- createAddCommit alice1 convId [bob] Just welcome <- pure commit.welcome es <- withWebSocket bob1 $ \wsBob -> do @@ -627,14 +642,14 @@ testLocalWelcome = do n <- awaitMatch isWelcome wsBob - shouldMatch (nPayload n %. "conversation") (objId qcnv) + shouldMatch (nPayload n %. "qualified_conversation") (convIdToQidObject convId) shouldMatch (nPayload n %. "from") (objId alice) shouldMatch (nPayload n %. "data") (B8.unpack (Base64.encode welcome)) pure es event <- assertOne =<< asList (es %. "events") event %. "type" `shouldMatch` "conversation.member-join" - event %. "conversation" `shouldMatch` objId qcnv + event %. "qualified_conversation" `shouldMatch` convIdToQidObject convId addedUser <- (event %. "data.users") >>= asList >>= assertOne objQid addedUser `shouldMatch` objQid bob @@ -643,19 +658,19 @@ testStaleCommit = do (alice : users) <- createAndConnectUsers (replicate 5 OwnDomain) let (users1, users2) = splitAt 2 users - (alice1 : clients) <- traverse (createMLSClient def) (alice : users) - traverse_ uploadNewKeyPackage clients - void $ createNewGroup alice1 + (alice1 : clients) <- traverse (createMLSClient def def) (alice : users) + traverse_ (uploadNewKeyPackage def) clients + convId <- createNewGroup def alice1 gsBackup <- getClientGroupState alice1 -- add the first batch of users to the conversation - void $ createAddCommit alice1 users1 >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId users1 >>= sendAndConsumeCommitBundle -- now roll back alice1 and try to add the second batch of users setClientGroupState alice1 gsBackup - mp <- createAddCommit alice1 users2 + mp <- createAddCommit alice1 convId users2 bindResponse (postMLSCommitBundle mp.sender (mkBundle mp)) $ \resp -> do resp.status `shouldMatchInt` 409 resp.json %. "label" `shouldMatch` "mls-stale-message" @@ -663,54 +678,54 @@ testStaleCommit = do testPropInvalidEpoch :: (HasCallStack) => App () testPropInvalidEpoch = do users@[_alice, bob, charlie, dee] <- createAndConnectUsers (replicate 4 OwnDomain) - [alice1, bob1, charlie1, dee1] <- traverse (createMLSClient def) users - void $ createNewGroup alice1 + [alice1, bob1, charlie1, dee1] <- traverse (createMLSClient def def) users + convId <- createNewGroup def alice1 -- Add bob -> epoch 1 - void $ uploadNewKeyPackage bob1 + void $ uploadNewKeyPackage def bob1 gsBackup <- getClientGroupState alice1 - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle gsBackup2 <- getClientGroupState alice1 -- try to send a proposal from an old epoch (0) do setClientGroupState alice1 gsBackup - void $ uploadNewKeyPackage dee1 - [prop] <- createAddProposals alice1 [dee] + void $ uploadNewKeyPackage def dee1 + [prop] <- createAddProposals convId alice1 [dee] bindResponse (postMLSMessage alice1 prop.message) $ \resp -> do resp.status `shouldMatchInt` 409 resp.json %. "label" `shouldMatch` "mls-stale-message" -- try to send a proposal from a newer epoch (2) do - void $ uploadNewKeyPackage dee1 - void $ uploadNewKeyPackage charlie1 + void $ uploadNewKeyPackage def dee1 + void $ uploadNewKeyPackage def charlie1 setClientGroupState alice1 gsBackup2 - void $ createAddCommit alice1 [charlie] -- --> epoch 2 - [prop] <- createAddProposals alice1 [dee] + void $ createAddCommit alice1 convId [charlie] -- --> epoch 2 + [prop] <- createAddProposals convId alice1 [dee] bindResponse (postMLSMessage alice1 prop.message) $ \resp -> do resp.status `shouldMatchInt` 409 resp.json %. "label" `shouldMatch` "mls-stale-message" -- remove charlie from users expected to get a welcome message - modifyMLSState $ \mls -> mls {newMembers = mempty} + modifyMLSState $ \mls -> mls {convs = Map.adjust (\conv -> conv {newMembers = mempty}) convId mls.convs} -- alice send a well-formed proposal and commits it - void $ uploadNewKeyPackage dee1 + void $ uploadNewKeyPackage def dee1 setClientGroupState alice1 gsBackup2 - createAddProposals alice1 [dee] >>= traverse_ sendAndConsumeMessage - void $ createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle + createAddProposals convId alice1 [dee] >>= traverse_ sendAndConsumeMessage + void $ createPendingProposalCommit convId alice1 >>= sendAndConsumeCommitBundle --- | This test submits a ReInit proposal, which is currently ignored by the -- backend, in order to check that unsupported proposal types are accepted. testPropUnsupported :: (HasCallStack) => App () testPropUnsupported = do users@[_alice, bob] <- createAndConnectUsers (replicate 2 OwnDomain) - [alice1, bob1] <- traverse (createMLSClient def) users - void $ uploadNewKeyPackage bob1 - void $ createNewGroup alice1 - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + [alice1, bob1] <- traverse (createMLSClient def def) users + void $ uploadNewKeyPackage def bob1 + convId <- createNewGroup def alice1 + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle - mp <- createReInitProposal alice1 + mp <- createReInitProposal convId alice1 -- we cannot consume this message, because the membership tag is fake void $ postMLSMessage mp.sender mp.message >>= getJSON 201 @@ -718,33 +733,33 @@ testPropUnsupported = do testAddUserBareProposalCommit :: (HasCallStack) => App () testAddUserBareProposalCommit = do [alice, bob] <- createAndConnectUsers (replicate 2 OwnDomain) - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - (_, qcnv) <- createNewGroup alice1 - void $ uploadNewKeyPackage bob1 - void $ createAddCommit alice1 [] >>= sendAndConsumeCommitBundle + [alice1, bob1] <- traverse (createMLSClient def def) [alice, bob] + convId <- createNewGroup def alice1 + void $ uploadNewKeyPackage def bob1 + void $ createAddCommit alice1 convId [] >>= sendAndConsumeCommitBundle - createAddProposals alice1 [bob] + createAddProposals convId alice1 [bob] >>= traverse_ sendAndConsumeMessage - commit <- createPendingProposalCommit alice1 + commit <- createPendingProposalCommit convId alice1 void $ assertJust "Expected welcome" commit.welcome void $ sendAndConsumeCommitBundle commit -- check that bob can now see the conversation convs <- getAllConvs bob - convIds <- traverse (%. "qualified_id") convs + convIds <- traverse objConvId convs void $ assertBool "Users added to an MLS group should find it when listing conversations" - (qcnv `elem` convIds) + (convId `elem` convIds) testPropExistingConv :: (HasCallStack) => App () testPropExistingConv = do [alice, bob] <- createAndConnectUsers (replicate 2 OwnDomain) - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - void $ uploadNewKeyPackage bob1 - void $ createNewGroup alice1 - void $ createAddCommit alice1 [] >>= sendAndConsumeCommitBundle - res <- createAddProposals alice1 [bob] >>= traverse sendAndConsumeMessage >>= assertOne + [alice1, bob1] <- traverse (createMLSClient def def) [alice, bob] + void $ uploadNewKeyPackage def bob1 + convId <- createNewGroup def alice1 + void $ createAddCommit alice1 convId [] >>= sendAndConsumeCommitBundle + res <- createAddProposals convId alice1 [bob] >>= traverse sendAndConsumeMessage >>= assertOne shouldBeEmpty (res %. "events") -- @SF.Separation @TSFI.RESTfulAPI @S2 @@ -755,20 +770,20 @@ testCommitNotReferencingAllProposals :: (HasCallStack) => App () testCommitNotReferencingAllProposals = do users@[_alice, bob, charlie] <- createAndConnectUsers (replicate 3 OwnDomain) - [alice1, bob1, charlie1] <- traverse (createMLSClient def) users - void $ createNewGroup alice1 - traverse_ uploadNewKeyPackage [bob1, charlie1] - void $ createAddCommit alice1 [] >>= sendAndConsumeCommitBundle + [alice1, bob1, charlie1] <- traverse (createMLSClient def def) users + convId <- createNewGroup def alice1 + traverse_ (uploadNewKeyPackage def) [bob1, charlie1] + void $ createAddCommit alice1 convId [] >>= sendAndConsumeCommitBundle gsBackup <- getClientGroupState alice1 -- create proposals for bob and charlie - createAddProposals alice1 [bob, charlie] + createAddProposals convId alice1 [bob, charlie] >>= traverse_ sendAndConsumeMessage -- now create a commit referencing only the first proposal setClientGroupState alice1 gsBackup - commit <- createPendingProposalCommit alice1 + commit <- createPendingProposalCommit convId alice1 -- send commit and expect and error bindResponse (postMLSCommitBundle alice1 (mkBundle commit)) $ \resp -> do @@ -779,12 +794,12 @@ testCommitNotReferencingAllProposals = do testUnsupportedCiphersuite :: (HasCallStack) => App () testUnsupportedCiphersuite = do - setMLSCiphersuite (Ciphersuite "0x0003") + let suite = (Ciphersuite "0x0003") alice <- randomUser OwnDomain def - alice1 <- createMLSClient def alice - void $ createNewGroup alice1 + alice1 <- createMLSClient suite def alice + convId <- createNewGroup suite alice1 - mp <- createPendingProposalCommit alice1 + mp <- createPendingProposalCommit convId alice1 bindResponse (postMLSCommitBundle alice1 (mkBundle mp)) $ \resp -> do resp.status `shouldMatchInt` 400 @@ -792,32 +807,35 @@ testUnsupportedCiphersuite = do testBackendRemoveProposal :: (HasCallStack) => Ciphersuite -> Domain -> App () testBackendRemoveProposal suite domain = do - setMLSCiphersuite suite [alice, bob] <- createAndConnectUsers [OwnDomain, domain] - (alice1 : bobClients) <- traverse (createMLSClient def) [alice, bob, bob] - traverse_ uploadNewKeyPackage bobClients - void $ createNewGroup alice1 + (alice1 : bobClients) <- traverse (createMLSClient suite def) [alice, bob, bob] + traverse_ (uploadNewKeyPackage suite) bobClients + convId <- createNewGroup suite alice1 - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle let isRemoveProposalFor :: Int -> Value -> App Bool isRemoveProposalFor index e = isNewMLSMessageNotif e &&~ do msgData <- e %. "payload.0.data" & asByteString - msg <- showMessage alice1 msgData + msg <- showMessage suite alice1 msgData fieldEquals msg "message.content.body.Proposal.Remove.removed" index withWebSocket alice1 \ws -> do deleteUser bob for_ (zip [1 ..] bobClients) \(index, _) -> do - void $ consumeMessageWithPredicate (isRemoveProposalFor index) alice1 Nothing ws + void $ consumeMessageWithPredicate (isRemoveProposalFor index) convId suite alice1 Nothing ws bobUser <- asString $ bob %. "id" modifyMLSState $ \mls -> mls - { members = Set.filter (\m -> m.user /= bobUser) mls.members + { convs = + Map.adjust + (\conv -> conv {members = Set.filter (\m -> m.user /= bobUser) conv.members}) + convId + mls.convs } -- alice commits the external proposals - r <- createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle + r <- createPendingProposalCommit convId alice1 >>= sendAndConsumeCommitBundle shouldBeEmpty $ r %. "events" diff --git a/integration/test/Test/MLS/KeyPackage.hs b/integration/test/Test/MLS/KeyPackage.hs index 5f95025a1da..2c49f0206d7 100644 --- a/integration/test/Test/MLS/KeyPackage.hs +++ b/integration/test/Test/MLS/KeyPackage.hs @@ -12,8 +12,8 @@ import Testlib.Prelude testDeleteKeyPackages :: App () testDeleteKeyPackages = do alice <- randomUser OwnDomain def - alice1 <- createMLSClient def alice - kps <- replicateM 3 (uploadNewKeyPackage alice1) + alice1 <- createMLSClient def def alice + kps <- replicateM 3 (uploadNewKeyPackage def alice1) -- add an extra non-existing key package to the delete request let kps' = "4B701F521EBE82CEC4AD5CB67FDD8E1C43FC4868DE32D03933CE4993160B75E8" : kps @@ -28,13 +28,12 @@ testDeleteKeyPackages = do testKeyPackageMultipleCiphersuites :: App () testKeyPackageMultipleCiphersuites = do alice <- randomUser OwnDomain def - [alice1, alice2] <- replicateM 2 (createMLSClient def alice) + [alice1, alice2] <- replicateM 2 (createMLSClient def def alice) - kp <- uploadNewKeyPackage alice2 + kp <- uploadNewKeyPackage def alice2 let suite = Ciphersuite "0xf031" - setMLSCiphersuite suite - void $ uploadNewKeyPackage alice2 + void $ uploadNewKeyPackage suite alice2 -- count key packages with default ciphersuite bindResponse (countKeyPackages def alice2) $ \resp -> do @@ -54,9 +53,9 @@ testKeyPackageMultipleCiphersuites = do testKeyPackageUploadNoKey :: App () testKeyPackageUploadNoKey = do alice <- randomUser OwnDomain def - alice1 <- createWireClient alice + alice1 <- createWireClient alice def - (kp, _) <- generateKeyPackage alice1 + (kp, _) <- generateKeyPackage alice1 def -- if we upload a keypackage without a key, -- we get a bad request @@ -73,14 +72,14 @@ testKeyPackageClaim :: App () testKeyPackageClaim = do alice <- randomUser OwnDomain def alices@[alice1, _alice2] <- replicateM 2 do - createMLSClient def alice + createMLSClient def def alice for_ alices \alicei -> replicateM 3 do - uploadNewKeyPackage alicei + uploadNewKeyPackage def alicei bob <- randomUser OwnDomain def bobs <- replicateM 3 do - createMLSClient def bob + createMLSClient def def bob for_ bobs \bobi -> claimKeyPackages def bobi alice `bindResponse` \resp -> do @@ -109,9 +108,9 @@ testKeyPackageSelfClaim :: App () testKeyPackageSelfClaim = do alice <- randomUser OwnDomain def alices@[alice1, alice2] <- replicateM 2 do - createMLSClient def alice + createMLSClient def def alice for_ alices \alicei -> replicateM 3 do - uploadNewKeyPackage alicei + uploadNewKeyPackage def alicei -- claim own keypackages claimKeyPackages def alice1 alice `bindResponse` \resp -> do @@ -133,7 +132,7 @@ testKeyPackageSelfClaim = do bob <- randomUser OwnDomain def bobs <- replicateM 2 do - createMLSClient def bob + createMLSClient def def bob -- skip own should only apply to own keypackages, hence -- bob claiming alices keypackages should work as normal @@ -152,13 +151,13 @@ testKeyPackageSelfClaim = do testKeyPackageRemoteClaim :: App () testKeyPackageRemoteClaim = do alice <- randomUser OwnDomain def - alice1 <- createMLSClient def alice + alice1 <- createMLSClient def def alice charlie <- randomUser OtherDomain def - charlie1 <- createMLSClient def charlie + charlie1 <- createMLSClient def def charlie - refCharlie <- uploadNewKeyPackage charlie1 - refAlice <- uploadNewKeyPackage alice1 + refCharlie <- uploadNewKeyPackage def charlie1 + refAlice <- uploadNewKeyPackage def alice1 -- the user should be able to claim the keypackage of -- a remote user and vice versa @@ -180,30 +179,28 @@ testKeyPackageRemoteClaim = do resp.status `shouldMatchInt` 200 testKeyPackageCount :: (HasCallStack) => Ciphersuite -> App () -testKeyPackageCount cs = do - setMLSCiphersuite cs +testKeyPackageCount suite = do alice <- randomUser OwnDomain def - alice1 <- createMLSClient def alice + alice1 <- createMLSClient suite def alice - bindResponse (countKeyPackages cs alice1) $ \resp -> do + bindResponse (countKeyPackages suite alice1) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "count" `shouldMatchInt` 0 let count = 10 - kps <- map fst <$> replicateM count (generateKeyPackage alice1) + kps <- map fst <$> replicateM count (generateKeyPackage alice1 suite) void $ uploadKeyPackages alice1 kps >>= getBody 201 - bindResponse (countKeyPackages cs alice1) $ \resp -> do + bindResponse (countKeyPackages suite alice1) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "count" `shouldMatchInt` count testUnsupportedCiphersuite :: (HasCallStack) => App () testUnsupportedCiphersuite = do let suite = Ciphersuite "0x0003" - setMLSCiphersuite suite bob <- randomUser OwnDomain def - bob1 <- createMLSClient def bob - (kp, _) <- generateKeyPackage bob1 + bob1 <- createMLSClient suite def bob + (kp, _) <- generateKeyPackage bob1 suite bindResponse (uploadKeyPackages bob1 [kp]) $ \resp -> do resp.status `shouldMatchInt` 400 resp.json %. "label" `shouldMatch` "mls-protocol-error" @@ -211,7 +208,7 @@ testUnsupportedCiphersuite = do testReplaceKeyPackages :: (HasCallStack) => App () testReplaceKeyPackages = do alice <- randomUser OwnDomain def - [alice1, alice2] <- replicateM 2 $ createMLSClient def alice + [alice1, alice2] <- replicateM 2 $ createMLSClient def def alice let suite = Ciphersuite "0xf031" let checkCount cs n = @@ -221,12 +218,11 @@ testReplaceKeyPackages = do -- setup: upload a batch of key packages for each ciphersuite void - $ replicateM 4 (fmap fst (generateKeyPackage alice1)) + $ replicateM 4 (fmap fst (generateKeyPackage alice1 def)) >>= uploadKeyPackages alice1 >>= getBody 201 - setMLSCiphersuite suite void - $ replicateM 5 (fmap fst (generateKeyPackage alice1)) + $ replicateM 5 (fmap fst (generateKeyPackage alice1 suite)) >>= uploadKeyPackages alice1 >>= getBody 201 @@ -235,7 +231,7 @@ testReplaceKeyPackages = do do -- generate a new batch of key packages - (kps, refs) <- unzip <$> replicateM 3 (generateKeyPackage alice1) + (kps, refs) <- unzip <$> replicateM 3 (generateKeyPackage alice1 suite) -- replace old key packages with new void $ replaceKeyPackages alice1 (Just [suite]) kps >>= getBody 201 @@ -261,7 +257,7 @@ testReplaceKeyPackages = do do -- replenish key packages for the second ciphersuite void - $ replicateM 5 (fmap fst (generateKeyPackage alice1)) + $ replicateM 5 (fmap fst (generateKeyPackage alice1 suite)) >>= uploadKeyPackages alice1 >>= getBody 201 @@ -269,10 +265,8 @@ testReplaceKeyPackages = do checkCount suite 5 -- replace all key packages with fresh ones - setMLSCiphersuite def - kps1 <- replicateM 2 (fmap fst (generateKeyPackage alice1)) - setMLSCiphersuite suite - kps2 <- replicateM 2 (fmap fst (generateKeyPackage alice1)) + kps1 <- replicateM 2 (fmap fst (generateKeyPackage alice1 def)) + kps2 <- replicateM 2 (fmap fst (generateKeyPackage alice1 suite)) void $ replaceKeyPackages alice1 (Just [def, suite]) (kps1 <> kps2) >>= getBody 201 @@ -280,10 +274,8 @@ testReplaceKeyPackages = do checkCount suite 2 do - setMLSCiphersuite def - defKeyPackages <- replicateM 3 (fmap fst (generateKeyPackage alice1)) - setMLSCiphersuite suite - suiteKeyPackages <- replicateM 3 (fmap fst (generateKeyPackage alice1)) + defKeyPackages <- replicateM 3 (fmap fst (generateKeyPackage alice1 def)) + suiteKeyPackages <- replicateM 3 (fmap fst (generateKeyPackage alice1 suite)) void $ replaceKeyPackages alice1 (Just []) [] diff --git a/integration/test/Test/MLS/Message.hs b/integration/test/Test/MLS/Message.hs index 81a194d3674..47708a53984 100644 --- a/integration/test/Test/MLS/Message.hs +++ b/integration/test/Test/MLS/Message.hs @@ -42,59 +42,59 @@ testApplicationMessage = do clients@[alice1, _alice2, alex1, _alex2, bob1, _bob2, _, _] <- traverse - (createMLSClient def) + (createMLSClient def def) [alice, alice, alex, alex, bob, bob, betty, betty] - traverse_ uploadNewKeyPackage clients - void $ createNewGroup alice1 + traverse_ (uploadNewKeyPackage def) clients + convId <- createNewGroup def alice1 withWebSockets [alice, alex, bob, betty] $ \wss -> do -- alice adds all other users (including her own client) - void $ createAddCommit alice1 [alice, alex, bob, betty] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [alice, alex, bob, betty] >>= sendAndConsumeCommitBundle traverse_ (awaitMatch isMemberJoinNotif) wss -- alex sends a message - void $ createApplicationMessage alex1 "hello" >>= sendAndConsumeMessage + void $ createApplicationMessage convId alex1 "hello" >>= sendAndConsumeMessage traverse_ (awaitMatch isNewMLSMessageNotif) wss -- bob sends a message - void $ createApplicationMessage bob1 "hey" >>= sendAndConsumeMessage + void $ createApplicationMessage convId bob1 "hey" >>= sendAndConsumeMessage traverse_ (awaitMatch isNewMLSMessageNotif) wss -- @END testAppMessageSomeReachable :: (HasCallStack) => App () testAppMessageSomeReachable = do - alice1 <- startDynamicBackends [mempty] $ \[thirdDomain] -> do + (alice1, convId) <- startDynamicBackends [mempty] $ \[thirdDomain] -> do ownDomain <- make OwnDomain & asString otherDomain <- make OtherDomain & asString [alice, bob, charlie] <- createAndConnectUsers [ownDomain, otherDomain, thirdDomain] - [alice1, bob1, charlie1] <- traverse (createMLSClient def) [alice, bob, charlie] - traverse_ uploadNewKeyPackage [bob1, charlie1] - void $ createNewGroup alice1 + [alice1, bob1, charlie1] <- traverse (createMLSClient def def) [alice, bob, charlie] + traverse_ (uploadNewKeyPackage def) [bob1, charlie1] + convId <- createNewGroup def alice1 void $ withWebSocket charlie $ \ws -> do - void $ createAddCommit alice1 [bob, charlie] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [bob, charlie] >>= sendAndConsumeCommitBundle awaitMatch isMemberJoinNotif ws - pure alice1 + pure (alice1, convId) -- charlie isn't able to receive this message, so we make sure we can post it -- successfully, but not attempt to consume it - mp <- createApplicationMessage alice1 "hi, bob!" + mp <- createApplicationMessage convId alice1 "hi, bob!" void $ postMLSMessage mp.sender mp.message >>= getJSON 201 testMessageNotifications :: (HasCallStack) => Domain -> App () testMessageNotifications bobDomain = do [alice, bob] <- createAndConnectUsers [OwnDomain, bobDomain] - [alice1, alice2, bob1, bob2] <- traverse (createMLSClient def) [alice, alice, bob, bob] + [alice1, alice2, bob1, bob2] <- traverse (createMLSClient def def) [alice, alice, bob, bob] bobClient <- bob1 %. "client_id" & asString - traverse_ uploadNewKeyPackage [alice1, alice2, bob1, bob2] + traverse_ (uploadNewKeyPackage def) [alice1, alice2, bob1, bob2] - void $ createNewGroup alice1 + convId <- createNewGroup def alice1 void $ withWebSocket bob $ \ws -> do - void $ createAddCommit alice1 [alice, bob] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [alice, bob] >>= sendAndConsumeCommitBundle awaitMatch isMemberJoinNotif ws let get (opts :: GetNotifications) = do @@ -106,7 +106,7 @@ testMessageNotifications bobDomain = do numNotifsClient <- get def {client = Just bobClient} void $ withWebSocket bob $ \ws -> do - void $ createApplicationMessage alice1 "hi bob" >>= sendAndConsumeMessage + void $ createApplicationMessage convId alice1 "hi bob" >>= sendAndConsumeMessage awaitMatch isNewMLSMessageNotif ws get def `shouldMatchInt` (numNotifs + 1) @@ -115,16 +115,16 @@ testMessageNotifications bobDomain = do testMultipleMessages :: (HasCallStack) => App () testMultipleMessages = do [alice, bob] <- createAndConnectUsers [OwnDomain, OtherDomain] - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - traverse_ uploadNewKeyPackage [alice1, bob1] - void $ createNewGroup alice1 + [alice1, bob1] <- traverse (createMLSClient def def) [alice, bob] + traverse_ (uploadNewKeyPackage def) [alice1, bob1] + convId <- createNewGroup def alice1 withWebSockets [bob] $ \wss -> do - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle traverse_ (awaitMatch isMemberJoinNotif) wss - void $ createApplicationMessage alice1 "hello" >>= sendAndConsumeMessage + void $ createApplicationMessage convId alice1 "hello" >>= sendAndConsumeMessage traverse_ (awaitMatch isNewMLSMessageNotif) wss - void $ createApplicationMessage alice1 "world" >>= sendAndConsumeMessage + void $ createApplicationMessage convId alice1 "world" >>= sendAndConsumeMessage traverse_ (awaitMatch isNewMLSMessageNotif) wss diff --git a/integration/test/Test/MLS/Notifications.hs b/integration/test/Test/MLS/Notifications.hs index 61a0b60d53f..a75d72276d2 100644 --- a/integration/test/Test/MLS/Notifications.hs +++ b/integration/test/Test/MLS/Notifications.hs @@ -9,12 +9,12 @@ import Testlib.Prelude testWelcomeNotification :: (HasCallStack) => App () testWelcomeNotification = do [alice, bob] <- createAndConnectUsers [OwnDomain, OtherDomain] - [alice1, alice2, bob1, bob2] <- traverse (createMLSClient def) [alice, alice, bob, bob] - traverse_ uploadNewKeyPackage [alice2, bob1, bob2] + [alice1, alice2, bob1, bob2] <- traverse (createMLSClient def def) [alice, alice, bob, bob] + traverse_ (uploadNewKeyPackage def) [alice2, bob1, bob2] - void $ createNewGroup alice1 + convId <- createNewGroup def alice1 notif <- withWebSocket bob $ \ws -> do - void $ createAddCommit alice1 [alice, bob] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [alice, bob] >>= sendAndConsumeCommitBundle awaitMatch isWelcomeNotif ws notifId <- notif %. "id" & asString diff --git a/integration/test/Test/MLS/One2One.hs b/integration/test/Test/MLS/One2One.hs index d93e5f582c2..660368b7660 100644 --- a/integration/test/Test/MLS/One2One.hs +++ b/integration/test/Test/MLS/One2One.hs @@ -23,6 +23,7 @@ import Control.Concurrent.Async import Control.Concurrent.MVar import qualified Data.ByteString.Base64 as Base64 import qualified Data.ByteString.Char8 as B8 +import qualified Data.Map as Map import qualified Data.Set as Set import qualified Data.Text as T import qualified Data.Text.Read as T @@ -116,16 +117,17 @@ testMLSOne2OneOtherMember scenario = do convDomain = one2OneScenarioConvDomain scenario bob <- createMLSOne2OnePartner otherDomain alice convDomain one2OneConv <- getMLSOne2OneConversation alice bob >>= getJSON 200 + one2OneConvId <- objConvId $ one2OneConv %. "conversation" do convId <- one2OneConv %. "conversation.qualified_id" bobOne2OneConv <- getMLSOne2OneConversation bob alice >>= getJSON 200 convId `shouldMatch` (bobOne2OneConv %. "conversation.qualified_id") - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - traverse_ uploadNewKeyPackage [bob1] - resetOne2OneGroup alice1 one2OneConv + [alice1, bob1] <- traverse (createMLSClient def def) [alice, bob] + void $ uploadNewKeyPackage def bob1 + resetOne2OneGroup def alice1 one2OneConv withWebSocket bob1 $ \ws -> do - commit <- createAddCommit alice1 [bob] + commit <- createAddCommit alice1 one2OneConvId [bob] void $ sendAndConsumeCommitBundle commit let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-welcome" n <- awaitMatch isMessage ws @@ -151,11 +153,12 @@ testMLSOne2OneRemoveClientLocalV5 = withVersion5 Version5 $ do [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] conv <- getMLSOne2OneConversationLegacy alice bob >>= getJSON 200 - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - traverse_ uploadNewKeyPackage [bob1] - resetGroup alice1 conv + [alice1, bob1] <- traverse (createMLSClient def def) [alice, bob] + void $ uploadNewKeyPackage def bob1 + convId <- objConvId conv + createGroup def alice1 convId - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle withWebSocket alice $ \wsAlice -> do _ <- deleteClient bob bob1.client >>= getBody 200 @@ -167,9 +170,9 @@ testMLSOne2OneRemoveClientLocalV5 = withVersion5 Version5 $ do mlsMsg <- asByteString (nPayload n %. "data") -- Checks that the remove proposal is consumable by alice - void $ mlsCliConsume alice1 mlsMsg + void $ mlsCliConsume convId def alice1 mlsMsg - parsedMsg <- showMessage alice1 mlsMsg + parsedMsg <- showMessage def alice1 mlsMsg let leafIndexBob = 1 -- msg `shouldMatch` "foo" parsedMsg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leafIndexBob @@ -198,15 +201,16 @@ testMLSOne2OneBlockedAfterConnected scenario = do convDomain = one2OneScenarioConvDomain scenario bob <- createMLSOne2OnePartner otherDomain alice convDomain one2OneConv <- getMLSOne2OneConversation alice bob >>= getJSON 200 + one2OneConvId <- objConvId $ one2OneConv %. "conversation" convId <- one2OneConv %. "conversation.qualified_id" do bobConv <- getMLSOne2OneConversation bob alice >>= getJSON 200 convId `shouldMatch` (bobConv %. "conversation.qualified_id") - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - traverse_ uploadNewKeyPackage [bob1] - resetOne2OneGroup alice1 one2OneConv - commit <- createAddCommit alice1 [bob] + [alice1, bob1] <- traverse (createMLSClient def def) [alice, bob] + void $ uploadNewKeyPackage def bob1 + resetOne2OneGroup def alice1 one2OneConv + commit <- createAddCommit alice1 one2OneConvId [bob] withWebSocket bob1 $ \ws -> do void $ sendAndConsumeCommitBundle commit let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-welcome" @@ -223,7 +227,7 @@ testMLSOne2OneBlockedAfterConnected scenario = do -- Bob. void $ getMLSOne2OneConversation alice bob >>= getJSON 403 - mp <- createApplicationMessage bob1 "hello, world, again" + mp <- createApplicationMessage one2OneConvId bob1 "hello, world, again" withWebSocket alice1 $ \ws -> do void $ postMLSMessage mp.sender mp.message >>= getJSON 201 awaitAnyEvent 2 ws `shouldMatch` (Nothing :: Maybe Value) @@ -237,16 +241,17 @@ testMLSOne2OneUnblocked scenario = do convDomain = one2OneScenarioConvDomain scenario bob <- createMLSOne2OnePartner otherDomain alice convDomain one2OneConv <- getMLSOne2OneConversation alice bob >>= getJSON 200 + one2OneConvId <- objConvId $ one2OneConv %. "conversation" do convId <- one2OneConv %. "conversation.qualified_id" bobConv <- getMLSOne2OneConversation bob alice >>= getJSON 200 convId `shouldMatch` (bobConv %. "conversation.qualified_id") - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - traverse_ uploadNewKeyPackage [bob1] - resetOne2OneGroup alice1 one2OneConv + [alice1, bob1] <- traverse (createMLSClient def def) [alice, bob] + void $ uploadNewKeyPackage def bob1 + resetOne2OneGroup def alice1 one2OneConv withWebSocket bob1 $ \ws -> do - commit <- createAddCommit alice1 [bob] + commit <- createAddCommit alice1 one2OneConvId [bob] void $ sendAndConsumeCommitBundle commit let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-welcome" n <- awaitMatch isMessage ws @@ -259,24 +264,24 @@ testMLSOne2OneUnblocked scenario = do -- Reset the group membership in the test setup as only 'bob1' is left in -- reality, even though the test state believes 'alice1' is still part of the -- conversation. - modifyMLSState $ \s -> s {members = Set.singleton bob1} + modifyMLSState $ \s -> s {convs = Map.adjust (\conv -> conv {members = Set.singleton bob1}) one2OneConvId s.convs} -- Bob creates a new client and adds it to the one-to-one conversation just so -- that the epoch advances. - bob2 <- createMLSClient def bob - traverse_ uploadNewKeyPackage [bob2] - void $ createAddCommit bob1 [bob] >>= sendAndConsumeCommitBundle + bob2 <- createMLSClient def def bob + void $ uploadNewKeyPackage def bob2 + void $ createAddCommit bob1 one2OneConvId [bob] >>= sendAndConsumeCommitBundle -- Alice finally unblocks Bob void $ putConnection alice bob "accepted" >>= getBody 200 void $ getMLSOne2OneConversation alice bob >>= getJSON 200 -- Alice rejoins via an external commit - void $ createExternalCommit alice1 Nothing >>= sendAndConsumeCommitBundle + void $ createExternalCommit one2OneConvId alice1 Nothing >>= sendAndConsumeCommitBundle -- Check that an application message can get to Bob withWebSockets [bob1, bob2] $ \wss -> do - mp <- createApplicationMessage alice1 "hello, I've always been here" + mp <- createApplicationMessage one2OneConvId alice1 "hello, I've always been here" void $ sendAndConsumeMessage mp let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-message-add" forM_ wss $ \ws -> do @@ -316,18 +321,18 @@ one2OneScenarioConvDomain One2OneScenarioRemoteConv = OtherDomain testMLSOne2One :: (HasCallStack) => Ciphersuite -> One2OneScenario -> App () testMLSOne2One suite scenario = do - setMLSCiphersuite suite alice <- randomUser OwnDomain def let otherDomain = one2OneScenarioUserDomain scenario convDomain = one2OneScenarioConvDomain scenario bob <- createMLSOne2OnePartner otherDomain alice convDomain - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - traverse_ uploadNewKeyPackage [bob1] + [alice1, bob1] <- traverse (createMLSClient suite def) [alice, bob] + void $ uploadNewKeyPackage suite bob1 one2OneConv <- getMLSOne2OneConversation alice bob >>= getJSON 200 - resetOne2OneGroup alice1 one2OneConv + one2OneConvId <- objConvId $ one2OneConv %. "conversation" + resetOne2OneGroup suite alice1 one2OneConv - commit <- createAddCommit alice1 [bob] + commit <- createAddCommit alice1 one2OneConvId [bob] withWebSocket bob1 $ \ws -> do void $ sendAndConsumeCommitBundle commit @@ -338,7 +343,7 @@ testMLSOne2One suite scenario = do void $ awaitMatch isMemberJoinNotif ws withWebSocket bob1 $ \ws -> do - mp <- createApplicationMessage alice1 "hello, world" + mp <- createApplicationMessage one2OneConvId alice1 "hello, world" void $ sendAndConsumeMessage mp let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-message-add" n <- awaitMatch isMessage ws @@ -346,7 +351,7 @@ testMLSOne2One suite scenario = do -- Send another commit. This verifies that the backend has correctly updated -- the cipersuite of this conversation. - void $ createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle + void $ createPendingProposalCommit one2OneConvId alice1 >>= sendAndConsumeCommitBundle one2OneConv' <- getMLSOne2OneConversation alice bob >>= getJSON 200 (suiteCode, _) <- assertOne $ T.hexadecimal (T.pack suite.code) @@ -360,10 +365,11 @@ testMLSOne2One suite scenario = do testMLSGhostOne2OneConv :: App () testMLSGhostOne2OneConv = do [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] - [alice1, bob1, bob2] <- traverse (createMLSClient def) [alice, bob, bob] - traverse_ uploadNewKeyPackage [bob1, bob2] + [alice1, bob1, bob2] <- traverse (createMLSClient def def) [alice, bob, bob] + traverse_ (uploadNewKeyPackage def) [bob1, bob2] one2OneConv <- getMLSOne2OneConversation alice bob >>= getJSON 200 - resetOne2OneGroup alice1 one2OneConv + one2OneConvId <- objConvId $ one2OneConv %. "conversation" + resetOne2OneGroup def alice1 one2OneConv doneVar <- liftIO $ newEmptyMVar let checkConversation = @@ -379,7 +385,7 @@ testMLSGhostOne2OneConv = do createCommit <- appToIO $ void - $ createAddCommit alice1 [bob] + $ createAddCommit alice1 one2OneConvId [bob] >>= sendAndConsumeCommitBundle liftIO $ withAsync checkConversationIO $ \a -> do @@ -409,8 +415,8 @@ testMLSFederationV1ConvOnOldBackend = do else createBob bob <- createBob - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - traverse_ uploadNewKeyPackage [alice1] + [alice1, bob1] <- traverse (createMLSClient def def) [alice, bob] + void $ uploadNewKeyPackage def alice1 -- Alice cannot start this conversation because it would exist on Bob's -- backend and Alice cannot get the MLS public keys of that backend. @@ -419,11 +425,12 @@ testMLSFederationV1ConvOnOldBackend = do fedError %. "label" `shouldMatch` "federation-version-error" conv <- getMLSOne2OneConversationLegacy bob alice >>= getJSON 200 + convId <- objConvId conv keys <- getMLSPublicKeys bob >>= getJSON 200 - resetOne2OneGroupGeneric bob1 conv keys + resetOne2OneGroupGeneric def bob1 conv keys withWebSocket alice1 $ \wsAlice -> do - commit <- createAddCommit bob1 [alice] + commit <- createAddCommit bob1 convId [alice] void $ sendAndConsumeCommitBundle commit let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-welcome" @@ -441,9 +448,9 @@ testMLSFederationV1ConvOnOldBackend = do mlsMsg <- asByteString (nPayload n %. "data") -- Checks that the remove proposal is consumable by bob - void $ mlsCliConsume bob1 mlsMsg + void $ mlsCliConsume convId def bob1 mlsMsg - parsedMsg <- showMessage bob1 mlsMsg + parsedMsg <- showMessage def bob1 mlsMsg let leafIndexAlice = 1 parsedMsg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leafIndexAlice parsedMsg %. "message.content.sender.External" `shouldMatchInt` 0 @@ -463,8 +470,8 @@ testMLSFederationV1ConvOnNewBackend = do else createBob bob <- createBob - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - traverse_ uploadNewKeyPackage [bob1] + [alice1, bob1] <- traverse (createMLSClient def def) [alice, bob] + void $ uploadNewKeyPackage def bob1 -- Bob cannot start this conversation because it would exist on Alice's -- backend and Bob cannot get the MLS public keys of that backend. @@ -473,11 +480,12 @@ testMLSFederationV1ConvOnNewBackend = do fedError %. "label" `shouldMatch` "federation-remote-error" one2OneConv <- getMLSOne2OneConversation alice bob >>= getJSON 200 + one2OneConvId <- objConvId $ one2OneConv %. "conversation" conv <- one2OneConv %. "conversation" - resetOne2OneGroup alice1 one2OneConv + resetOne2OneGroup def alice1 one2OneConv withWebSocket bob1 $ \wsBob -> do - commit <- createAddCommit alice1 [bob] + commit <- createAddCommit alice1 one2OneConvId [bob] void $ sendAndConsumeCommitBundle commit let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-welcome" @@ -495,9 +503,9 @@ testMLSFederationV1ConvOnNewBackend = do mlsMsg <- asByteString (nPayload n %. "data") -- Checks that the remove proposal is consumable by bob - void $ mlsCliConsume alice1 mlsMsg + void $ mlsCliConsume one2OneConvId def alice1 mlsMsg - parsedMsg <- showMessage alice1 mlsMsg + parsedMsg <- showMessage def alice1 mlsMsg let leafIndexBob = 1 parsedMsg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leafIndexBob parsedMsg %. "message.content.sender.External" `shouldMatchInt` 0 diff --git a/integration/test/Test/MLS/SubConversation.hs b/integration/test/Test/MLS/SubConversation.hs index 83c5376edf3..40cf1e66bf0 100644 --- a/integration/test/Test/MLS/SubConversation.hs +++ b/integration/test/Test/MLS/SubConversation.hs @@ -3,6 +3,7 @@ module Test.MLS.SubConversation where import API.Galley import Control.Monad.Trans (lift) import Control.Monad.Trans.Maybe (MaybeT (runMaybeT)) +import qualified Data.Map as Map import qualified Data.Set as Set import MLS.Util import Notifications @@ -13,44 +14,47 @@ import Testlib.Prelude testJoinSubConv :: App () testJoinSubConv = do [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] - [alice1, bob1, bob2] <- traverse (createMLSClient def) [alice, bob, bob] - traverse_ uploadNewKeyPackage [bob1, bob2] - (_, qcnv) <- createNewGroup alice1 + [alice1, bob1, bob2] <- traverse (createMLSClient def def) [alice, bob, bob] + traverse_ (uploadNewKeyPackage def) [bob1, bob2] + convId <- createNewGroup def alice1 - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle - createSubConv bob1 "conference" + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle + void $ createSubConv def convId bob1 "conference" -- bob adds his first client to the subconversation - sub' <- getSubConversation bob qcnv "conference" >>= getJSON 200 + sub' <- getSubConversation bob convId "conference" >>= getJSON 200 + subConvId <- objConvId sub' do tm <- sub' %. "epoch_timestamp" assertBool "Epoch timestamp should not be null" (tm /= Null) -- now alice joins with her own client void - $ createExternalCommit alice1 Nothing + $ createExternalCommit subConvId alice1 Nothing >>= sendAndConsumeCommitBundle testJoinOne2OneSubConv :: App () testJoinOne2OneSubConv = do [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] - [alice1, bob1, bob2] <- traverse (createMLSClient def) [alice, bob, bob] - traverse_ uploadNewKeyPackage [bob1, bob2] + [alice1, bob1, bob2] <- traverse (createMLSClient def def) [alice, bob, bob] + traverse_ (uploadNewKeyPackage def) [bob1, bob2] one2OneConv <- getMLSOne2OneConversation alice bob >>= getJSON 200 - resetOne2OneGroup alice1 one2OneConv + one2OneConvId <- objConvId (one2OneConv %. "conversation") + resetOne2OneGroup def alice1 one2OneConv - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle - createOne2OneSubConv bob1 "conference" (one2OneConv %. "public_keys") + void $ createAddCommit alice1 one2OneConvId [bob] >>= sendAndConsumeCommitBundle + createOne2OneSubConv def one2OneConvId bob1 "conference" (one2OneConv %. "public_keys") -- bob adds his first client to the subconversation - sub' <- getSubConversation bob (one2OneConv %. "conversation") "conference" >>= getJSON 200 + sub' <- getSubConversation bob one2OneConvId "conference" >>= getJSON 200 + subConvId <- objConvId sub' do tm <- sub' %. "epoch_timestamp" assertBool "Epoch timestamp should not be null" (tm /= Null) -- now alice joins with her own client void - $ createExternalCommit alice1 Nothing + $ createExternalCommit subConvId alice1 Nothing >>= sendAndConsumeCommitBundle testLeaveOne2OneSubConv :: One2OneScenario -> Leaver -> App () @@ -60,15 +64,18 @@ testLeaveOne2OneSubConv scenario leaver = do let otherDomain = one2OneScenarioUserDomain scenario convDomain = one2OneScenarioConvDomain scenario bob <- createMLSOne2OnePartner otherDomain alice convDomain - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - traverse_ uploadNewKeyPackage [bob1] + [alice1, bob1] <- traverse (createMLSClient def def) [alice, bob] + void $ uploadNewKeyPackage def bob1 one2OneConv <- getMLSOne2OneConversation alice bob >>= getJSON 200 - resetOne2OneGroup alice1 one2OneConv - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + one2OneConvId <- objConvId $ one2OneConv %. "conversation" + resetOne2OneGroup def alice1 one2OneConv + void $ createAddCommit alice1 one2OneConvId [bob] >>= sendAndConsumeCommitBundle -- create and join subconversation - createOne2OneSubConv alice1 "conference" (one2OneConv %. "public_keys") - void $ createExternalCommit bob1 Nothing >>= sendAndConsumeCommitBundle + createOne2OneSubConv def one2OneConvId alice1 "conference" (one2OneConv %. "public_keys") + subConvId <- getSubConvId bob one2OneConvId "conference" + + void $ createExternalCommit subConvId bob1 Nothing >>= sendAndConsumeCommitBundle -- one of the two clients leaves let (leaverClient, leaverIndex, remainingClient) = case leaver of @@ -76,14 +83,13 @@ testLeaveOne2OneSubConv scenario leaver = do Bob -> (bob1, 1, alice1) withWebSocket remainingClient $ \ws -> do - leaveCurrentConv leaverClient - - msg <- consumeMessage remainingClient Nothing ws + leaveConv subConvId leaverClient + msg <- consumeMessage subConvId def remainingClient Nothing ws msg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leaverIndex msg %. "message.content.sender.External" `shouldMatchInt` 0 -- the other client commits the pending proposal - void $ createPendingProposalCommit remainingClient >>= sendAndConsumeCommitBundle + void $ createPendingProposalCommit subConvId remainingClient >>= sendAndConsumeCommitBundle testDeleteParentOfSubConv :: (HasCallStack) => Domain -> App () testDeleteParentOfSubConv secondDomain = do @@ -91,38 +97,39 @@ testDeleteParentOfSubConv secondDomain = do bob <- randomUser secondDomain def connectUsers [alice, bob] - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - traverse_ uploadNewKeyPackage [alice1, bob1] - (_, qcnv) <- createNewGroup alice1 - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + [alice1, bob1] <- traverse (createMLSClient def def) [alice, bob] + traverse_ (uploadNewKeyPackage def) [alice1, bob1] + convId <- createNewGroup def alice1 + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle -- bob creates a subconversation and adds his own client - createSubConv bob1 "conference" + createSubConv def convId bob1 "conference" + subConvId <- getSubConvId bob convId "conference" -- alice joins with her own client - void $ createExternalCommit alice1 Nothing >>= sendAndConsumeCommitBundle + void $ createExternalCommit subConvId alice1 Nothing >>= sendAndConsumeCommitBundle -- bob sends a message to the subconversation do - mp <- createApplicationMessage bob1 "hello, alice" + mp <- createApplicationMessage subConvId bob1 "hello, alice" void . bindResponse (postMLSMessage mp.sender mp.message) $ \resp -> do resp.status `shouldMatchInt` 201 -- alice sends a message to the subconversation do - mp <- createApplicationMessage bob1 "hello, bob" + mp <- createApplicationMessage subConvId bob1 "hello, bob" void . bindResponse (postMLSMessage mp.sender mp.message) $ \resp -> do resp.status `shouldMatchInt` 201 -- alice deletes main conversation withWebSocket bob $ \ws -> do - void . bindResponse (deleteTeamConv tid qcnv alice) $ \resp -> do + void . bindResponse (deleteTeamConv tid (convIdToQidObject convId) alice) $ \resp -> do resp.status `shouldMatchInt` 200 void $ awaitMatch isConvDeleteNotif ws -- bob fails to send a message to the subconversation do - mp <- createApplicationMessage bob1 "hello, alice" + mp <- createApplicationMessage subConvId bob1 "hello, alice" void . bindResponse (postMLSMessage mp.sender mp.message) $ \resp -> do resp.status `shouldMatchInt` 404 case secondDomain of @@ -131,7 +138,7 @@ testDeleteParentOfSubConv secondDomain = do -- alice fails to send a message to the subconversation do - mp <- createApplicationMessage alice1 "hello, bob" + mp <- createApplicationMessage subConvId alice1 "hello, bob" void . bindResponse (postMLSMessage mp.sender mp.message) $ \resp -> do resp.status `shouldMatchInt` 404 resp.json %. "label" `shouldMatch` "no-conversation" @@ -140,21 +147,21 @@ testDeleteSubConversation :: (HasCallStack) => Domain -> App () testDeleteSubConversation otherDomain = do [alice, bob] <- createAndConnectUsers [OwnDomain, otherDomain] charlie <- randomUser OwnDomain def - [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] - void $ uploadNewKeyPackage bob1 - (_, qcnv) <- createNewGroup alice1 - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + [alice1, bob1] <- traverse (createMLSClient def def) [alice, bob] + void $ uploadNewKeyPackage def bob1 + convId <- createNewGroup def alice1 + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle - createSubConv alice1 "conference1" - sub1 <- getSubConversation alice qcnv "conference1" >>= getJSON 200 + createSubConv def convId alice1 "conference1" + sub1 <- getSubConversation alice convId "conference1" >>= getJSON 200 void $ deleteSubConversation charlie sub1 >>= getBody 403 void $ deleteSubConversation alice sub1 >>= getBody 200 - createSubConv alice1 "conference2" - sub2 <- getSubConversation alice qcnv "conference2" >>= getJSON 200 + createSubConv def convId alice1 "conference2" + sub2 <- getSubConversation alice convId "conference2" >>= getJSON 200 void $ deleteSubConversation bob sub2 >>= getBody 200 - sub2' <- getSubConversation alice1 qcnv "conference2" >>= getJSON 200 + sub2' <- getSubConversation alice1 convId "conference2" >>= getJSON 200 sub2 `shouldNotMatch` sub2' data Leaver = Alice | Bob @@ -163,18 +170,19 @@ data Leaver = Alice | Bob testLeaveSubConv :: (HasCallStack) => Leaver -> App () testLeaveSubConv leaver = do [alice, bob, charlie] <- createAndConnectUsers [OwnDomain, OwnDomain, OtherDomain] - clients@[alice1, bob1, bob2, charlie1] <- traverse (createMLSClient def) [alice, bob, bob, charlie] - traverse_ uploadNewKeyPackage [bob1, bob2, charlie1] - void $ createNewGroup alice1 + clients@[alice1, bob1, bob2, charlie1] <- traverse (createMLSClient def def) [alice, bob, bob, charlie] + traverse_ (uploadNewKeyPackage def) [bob1, bob2, charlie1] + convId <- createNewGroup def alice1 withWebSockets [bob, charlie] $ \wss -> do - void $ createAddCommit alice1 [bob, charlie] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [bob, charlie] >>= sendAndConsumeCommitBundle traverse_ (awaitMatch isMemberJoinNotif) wss - createSubConv bob1 "conference" - void $ createExternalCommit alice1 Nothing >>= sendAndConsumeCommitBundle - void $ createExternalCommit bob2 Nothing >>= sendAndConsumeCommitBundle - void $ createExternalCommit charlie1 Nothing >>= sendAndConsumeCommitBundle + createSubConv def convId bob1 "conference" + subConvId <- getSubConvId bob convId "conference" + void $ createExternalCommit subConvId alice1 Nothing >>= sendAndConsumeCommitBundle + void $ createExternalCommit subConvId bob2 Nothing >>= sendAndConsumeCommitBundle + void $ createExternalCommit subConvId charlie1 Nothing >>= sendAndConsumeCommitBundle -- a member leaves the subconversation let (firstLeaver, idxFirstLeaver) = case leaver of @@ -184,150 +192,163 @@ testLeaveSubConv leaver = do let others = filter (/= firstLeaver) clients withWebSockets others $ \wss -> do - leaveCurrentConv firstLeaver + leaveConv subConvId firstLeaver for_ (zip others wss) $ \(cid, ws) -> do - msg <- consumeMessage cid Nothing ws + msg <- consumeMessage subConvId def cid Nothing ws msg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` idxFirstLeaver msg %. "message.content.sender.External" `shouldMatchInt` 0 withWebSockets (tail others) $ \wss -> do -- a member commits the pending proposal - void $ createPendingProposalCommit (head others) >>= sendAndConsumeCommitBundle + void $ createPendingProposalCommit subConvId (head others) >>= sendAndConsumeCommitBundle traverse_ (awaitMatch isNewMLSMessageNotif) wss -- send an application message - void $ createApplicationMessage (head others) "good riddance" >>= sendAndConsumeMessage + void $ createApplicationMessage subConvId (head others) "good riddance" >>= sendAndConsumeMessage traverse_ (awaitMatch isNewMLSMessageNotif) wss -- check that only 3 clients are left in the subconv do - conv <- getCurrentConv (head others) + conv <- getConv subConvId (head others) mems <- conv %. "members" & asList length mems `shouldMatchInt` 3 -- charlie1 leaves let others' = filter (/= charlie1) others withWebSockets others' $ \wss -> do - leaveCurrentConv charlie1 + leaveConv subConvId charlie1 for_ (zip others' wss) $ \(cid, ws) -> do - msg <- consumeMessage cid Nothing ws + msg <- consumeMessage subConvId def cid Nothing ws msg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` idxCharlie1 msg %. "message.content.sender.External" `shouldMatchInt` 0 -- a member commits the pending proposal - void $ createPendingProposalCommit (head others') >>= sendAndConsumeCommitBundle + void $ createPendingProposalCommit subConvId (head others') >>= sendAndConsumeCommitBundle -- check that only 2 clients are left in the subconv do - conv <- getCurrentConv (head others) + conv <- getConv subConvId (head others) mems <- conv %. "members" & asList length mems `shouldMatchInt` 2 testCreatorRemovesUserFromParent :: App () testCreatorRemovesUserFromParent = do [alice, bob, charlie] <- createAndConnectUsers [OwnDomain, OwnDomain, OtherDomain] - [alice1, bob1, bob2, charlie1, charlie2] <- traverse (createMLSClient def) [alice, bob, bob, charlie, charlie] - traverse_ uploadNewKeyPackage [bob1, bob2, charlie1, charlie2] - (_, qcnv) <- createNewGroup alice1 - - _ <- createAddCommit alice1 [bob, charlie] >>= sendAndConsumeCommitBundle - - -- save the state of the parent group - parentState <- getMLSState - -- switch to the subgroup - let subConvName = "conference" - createSubConv alice1 subConvName - - for_ [bob1, bob2, charlie1, charlie2] \c -> - createExternalCommit c Nothing >>= sendAndConsumeCommitBundle - -- save the state of the subgroup and switch to the parent context - childState <- getMLSState <* setMLSState parentState - withWebSockets [alice1, charlie1, charlie2] \wss -> do - removeCommitEvents <- createRemoveCommit alice1 [bob1, bob2] >>= sendAndConsumeCommitBundle - modifyMLSState $ \s -> s {members = s.members Set.\\ Set.fromList [bob1, bob2]} - - removeCommitEvents %. "events.0.type" `shouldMatch` "conversation.member-leave" - removeCommitEvents %. "events.0.data.reason" `shouldMatch` "removed" - removeCommitEvents %. "events.0.from" `shouldMatch` alice1.user - - for_ wss \ws -> do - n <- awaitMatch isConvLeaveNotif ws - n %. "payload.0.data.reason" `shouldMatch` "removed" - n %. "payload.0.from" `shouldMatch` alice1.user - - setMLSState childState - let idxBob1 :: Int = 1 - idxBob2 :: Int = 2 - for_ ((,) <$> [idxBob1, idxBob2] <*> wss) \(idx, ws) -> do - msg <- - awaitMatch - do - \n -> - isJust <$> runMaybeT do - msg <- lift $ n %. "payload.0.data" & asByteString >>= showMessage alice1 - guard =<< lift do - isNewMLSMessageNotif n - - prop <- - maybe mzero pure =<< lift do - lookupField msg "message.content.body.Proposal" - - lift do - (== idx) <$> (prop %. "Remove.removed" & asInt) - ws - for_ ws.client $ \consumer -> - msg %. "payload.0.data" & asByteString >>= mlsCliConsume consumer - - -- remove bob from the child state - modifyMLSState $ \s -> s {members = s.members Set.\\ Set.fromList [bob1, bob2]} - - _ <- createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle - - getSubConversation bob qcnv subConvName >>= flip withResponse \resp -> - assertBool "access to the conversation for bob should be denied" (resp.status == 403) - - for_ [charlie, alice] \m -> do - resp <- getSubConversation m qcnv subConvName - assertBool "alice and charlie should have access to the conversation" (resp.status == 200) - mems <- resp.jsonBody %. "members" & asList - mems `shouldMatchSet` ((renameField "id" "user_id" <=< make) `traverse` [alice1, charlie1, charlie2]) + addUsersToFailureContext [("alice", alice), ("bob", bob), ("charlie", charlie)] $ do + [alice1, bob1, bob2, charlie1, charlie2] <- traverse (createMLSClient def def) [alice, bob, bob, charlie, charlie] + traverse_ (uploadNewKeyPackage def) [bob1, bob2, charlie1, charlie2] + convId <- createNewGroup def alice1 + + _ <- createAddCommit alice1 convId [bob, charlie] >>= sendAndConsumeCommitBundle + + -- save the state of the parent group + let subConvName = "conference" + createSubConv def convId alice1 subConvName + subConvId <- getSubConvId alice convId "conference" + + for_ [bob1, bob2, charlie1, charlie2] \c -> + createExternalCommit subConvId c Nothing >>= sendAndConsumeCommitBundle + + withWebSockets [alice1, charlie1, charlie2] \wss -> do + removeCommitEvents <- createRemoveCommit alice1 convId [bob1, bob2] >>= sendAndConsumeCommitBundle + modifyMLSState $ \s -> + s + { convs = + Map.adjust + (\conv -> conv {members = conv.members Set.\\ Set.fromList [bob1, bob2]}) + convId + s.convs + } + + removeCommitEvents %. "events.0.type" `shouldMatch` "conversation.member-leave" + removeCommitEvents %. "events.0.data.reason" `shouldMatch` "removed" + removeCommitEvents %. "events.0.from" `shouldMatch` alice1.user + + for_ wss \ws -> do + n <- awaitMatch isConvLeaveNotif ws + n %. "payload.0.data.reason" `shouldMatch` "removed" + n %. "payload.0.from" `shouldMatch` alice1.user + + let idxBob1 :: Int = 1 + idxBob2 :: Int = 2 + for_ ((,) <$> [idxBob1, idxBob2] <*> wss) \(idx, ws) -> do + msg <- + awaitMatch + do + \n -> + isJust <$> runMaybeT do + msg <- lift $ n %. "payload.0.data" & asByteString >>= showMessage def alice1 + guard =<< lift do + isNewMLSMessageNotif n + + prop <- + maybe mzero pure =<< lift do + lookupField msg "message.content.body.Proposal" + + lift do + (== idx) <$> (prop %. "Remove.removed" & asInt) + ws + for_ ws.client $ \consumer -> + msg %. "payload.0.data" & asByteString >>= mlsCliConsume subConvId def consumer + + -- remove bob from the child state + modifyMLSState $ \s -> + s + { convs = + Map.adjust + (\conv -> conv {members = conv.members Set.\\ Set.fromList [bob1, bob2]}) + subConvId + s.convs + } + + _ <- createPendingProposalCommit subConvId alice1 >>= sendAndConsumeCommitBundle + + getSubConversation bob convId subConvName >>= flip withResponse \resp -> + assertBool "access to the conversation for bob should be denied" (resp.status == 403) + + for_ [charlie, alice] \m -> do + resp <- getSubConversation m convId subConvName + assertBool "alice and charlie should have access to the conversation" (resp.status == 200) + mems <- resp.jsonBody %. "members" & asList + mems `shouldMatchSet` ((renameField "id" "user_id" <=< make) `traverse` [alice1, charlie1, charlie2]) testResendingProposals :: (HasCallStack) => App () testResendingProposals = do [alice, bob, charlie] <- createAndConnectUsers [OwnDomain, OwnDomain, OtherDomain] [alice1, alice2, bob1, bob2, bob3, charlie1] <- traverse - (createMLSClient def) + (createMLSClient def def) [alice, alice, bob, bob, bob, charlie] - traverse_ uploadNewKeyPackage [alice2, bob1, bob2, bob3, charlie1] + traverse_ (uploadNewKeyPackage def) [alice2, bob1, bob2, bob3, charlie1] - (_, conv) <- createNewGroup alice1 - void $ createAddCommit alice1 [alice, bob, charlie] >>= sendAndConsumeCommitBundle + conv <- createNewGroup def alice1 + void $ createAddCommit alice1 conv [alice, bob, charlie] >>= sendAndConsumeCommitBundle - createSubConv alice1 "conference" + createSubConv def conv alice1 "conference" + subConvId <- getSubConvId alice conv "conference" - void $ createExternalCommit alice2 Nothing >>= sendAndConsumeCommitBundle - void $ createExternalCommit bob1 Nothing >>= sendAndConsumeCommitBundle - void $ createExternalCommit bob2 Nothing >>= sendAndConsumeCommitBundle - void $ createExternalCommit bob3 Nothing >>= sendAndConsumeCommitBundle + void $ createExternalCommit subConvId alice2 Nothing >>= sendAndConsumeCommitBundle + void $ createExternalCommit subConvId bob1 Nothing >>= sendAndConsumeCommitBundle + void $ createExternalCommit subConvId bob2 Nothing >>= sendAndConsumeCommitBundle + void $ createExternalCommit subConvId bob3 Nothing >>= sendAndConsumeCommitBundle - leaveCurrentConv bob1 - leaveCurrentConv bob2 - leaveCurrentConv bob3 + leaveConv subConvId bob1 + leaveConv subConvId bob2 + leaveConv subConvId bob3 - mls <- getMLSState - withWebSockets (charlie1 : toList mls.members) \wss -> do - void $ createExternalCommit charlie1 Nothing >>= sendAndConsumeCommitBundle + subConv <- getMLSConv subConvId + withWebSockets (charlie1 : toList subConv.members) \wss -> do + void $ createExternalCommit subConvId charlie1 Nothing >>= sendAndConsumeCommitBundle -- consume proposals after backend resends them for_ wss \ws -> do replicateM 3 do - msg <- consumeMessage (fromJust ws.client) Nothing ws + msg <- consumeMessage subConvId def (fromJust ws.client) Nothing ws msg %. "message.content.sender.External" `shouldMatchInt` 0 - void $ createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle + void $ createPendingProposalCommit subConvId alice1 >>= sendAndConsumeCommitBundle sub <- getSubConversation alice1 conv "conference" >>= getJSON 200 let members = diff --git a/integration/test/Test/MLS/Unreachable.hs b/integration/test/Test/MLS/Unreachable.hs index 4e32d293508..17bd296650a 100644 --- a/integration/test/Test/MLS/Unreachable.hs +++ b/integration/test/Test/MLS/Unreachable.hs @@ -33,13 +33,13 @@ testAddUsersSomeReachable = do otherDomain <- make OtherDomain & asString [alice, bob, charlie] <- createAndConnectUsers [ownDomain, otherDomain, thirdDomain] - [alice1, bob1, charlie1] <- traverse (createMLSClient def) [alice, bob, charlie] - traverse_ uploadNewKeyPackage [bob1, charlie1] - void $ createNewGroup alice1 + [alice1, bob1, charlie1] <- traverse (createMLSClient def def) [alice, bob, charlie] + traverse_ (uploadNewKeyPackage def) [bob1, charlie1] + convId <- createNewGroup def alice1 void $ withWebSocket bob $ \ws -> do - void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle awaitMatch isMemberJoinNotif ws - mp <- createAddCommit alice1 [charlie] + mp <- createAddCommit alice1 convId [charlie] pure (mp, thirdDomain) -- try adding Charlie now that his backend is unreachable @@ -52,24 +52,24 @@ testAddUserWithUnreachableRemoteUsers :: (HasCallStack) => App () testAddUserWithUnreachableRemoteUsers = do resourcePool <- asks resourcePool runCodensity (acquireResources 1 resourcePool) $ \[cDom] -> do - (alice1, bob, brad, chris) <- runCodensity (startDynamicBackend cDom mempty) $ \_ -> do + (alice1, bob, brad, chris, convId) <- runCodensity (startDynamicBackend cDom mempty) $ \_ -> do [own, other] <- forM [OwnDomain, OtherDomain] $ asString . make [alice, bob, brad, charlie, chris] <- createAndConnectUsers [own, other, other, cDom.berDomain, cDom.berDomain] [alice1, charlie1, chris1] <- - traverse (createMLSClient def) [alice, charlie, chris] - traverse_ uploadNewKeyPackage [charlie1, chris1] - void $ createNewGroup alice1 + traverse (createMLSClient def def) [alice, charlie, chris] + traverse_ (uploadNewKeyPackage def) [charlie1, chris1] + convId <- createNewGroup def alice1 void $ withWebSocket charlie $ \ws -> do - void $ createAddCommit alice1 [charlie] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [charlie] >>= sendAndConsumeCommitBundle awaitMatch isMemberJoinNotif ws - pure (alice1, bob, brad, chris) + pure (alice1, bob, brad, chris, convId) - [bob1, brad1] <- traverse (createMLSClient def) [bob, brad] - traverse_ uploadNewKeyPackage [bob1, brad1] + [bob1, brad1] <- traverse (createMLSClient def def) [bob, brad] + traverse_ (uploadNewKeyPackage def) [bob1, brad1] do - mp <- createAddCommit alice1 [bob] + mp <- createAddCommit alice1 convId [bob] bindResponse (postMLSCommitBundle mp.sender (mkBundle mp)) $ \resp -> do resp.status `shouldMatchInt` 533 resp.jsonBody %. "unreachable_backends" `shouldMatchSet` [cDom.berDomain] @@ -78,12 +78,12 @@ testAddUserWithUnreachableRemoteUsers = do void $ postMLSCommitBundle mp.sender (mkBundle mp) >>= getBody 201 do - mp <- createAddCommit alice1 [brad] + mp <- createAddCommit alice1 convId [brad] void $ postMLSCommitBundle mp.sender (mkBundle mp) >>= getBody 201 do mp <- runCodensity (startDynamicBackend cDom mempty) $ \_ -> - createAddCommit alice1 [chris] + createAddCommit alice1 convId [chris] bindResponse (postMLSCommitBundle mp.sender (mkBundle mp)) $ \resp -> do resp.status `shouldMatchInt` 533 resp.jsonBody %. "unreachable_backends" `shouldMatchSet` [cDom.berDomain] @@ -98,13 +98,13 @@ testAddUnreachableUserFromFederatingBackend = do [alice, bob, charlie, chad] <- createAndConnectUsers [ownDomain, otherDomain, cDom.berDomain, cDom.berDomain] - [alice1, bob1, charlie1, chad1] <- traverse (createMLSClient def) [alice, bob, charlie, chad] - traverse_ uploadNewKeyPackage [bob1, charlie1, chad1] - void $ createNewGroup alice1 + [alice1, bob1, charlie1, chad1] <- traverse (createMLSClient def def) [alice, bob, charlie, chad] + traverse_ (uploadNewKeyPackage def) [bob1, charlie1, chad1] + convId <- createNewGroup def alice1 withWebSockets [bob, charlie] $ \wss -> do - void $ createAddCommit alice1 [bob, charlie] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 convId [bob, charlie] >>= sendAndConsumeCommitBundle forM_ wss $ awaitMatch isMemberJoinNotif - createAddCommit alice1 [chad] + createAddCommit alice1 convId [chad] bindResponse (postMLSCommitBundle mp.sender (mkBundle mp)) $ \resp -> do resp.status `shouldMatchInt` 533 diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs index c18a517d2ea..c58e73d36ed 100644 --- a/integration/test/Test/Spar.hs +++ b/integration/test/Test/Spar.hs @@ -10,7 +10,6 @@ import API.Spar import Control.Concurrent (threadDelay) import Data.Vector (fromList) import qualified Data.Vector as Vector -import SAML2.WebSSO.Test.Util (SampleIdP (..), makeSampleIdPMetadata) import SetupHelpers import Testlib.JSON import Testlib.PTest @@ -19,7 +18,7 @@ import Testlib.Prelude testSparUserCreationInvitationTimeout :: (HasCallStack) => App () testSparUserCreationInvitationTimeout = do (owner, _tid, _) <- createTeam OwnDomain 1 - tok <- createScimToken owner >>= \resp -> resp.json %. "token" >>= asString + tok <- createScimTokenV6 owner def >>= \resp -> resp.json %. "token" >>= asString scimUser <- randomScimUser bindResponse (createScimUser OwnDomain tok scimUser) $ \res -> do res.status `shouldMatchInt` 201 @@ -41,7 +40,7 @@ testSparExternalIdDifferentFromEmailWithIdp = do (owner, tid, _) <- createTeam OwnDomain 1 void $ setTeamFeatureStatus owner tid "sso" "enabled" void $ registerTestIdPWithMeta owner >>= getJSON 201 - tok <- createScimToken owner >>= getJSON 200 >>= (%. "token") >>= asString + tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString email <- randomEmail extId <- randomExternalId scimUser <- randomScimUserWith extId email @@ -127,15 +126,10 @@ testSparExternalIdDifferentFromEmailWithIdp = do subject <- u %. "sso_id.subject" >>= asString subject `shouldContainString` currentExtId -registerTestIdPWithMeta :: (HasCallStack, MakesValue owner) => owner -> App Response -registerTestIdPWithMeta owner = do - SampleIdP idpmeta _ _ _ <- makeSampleIdPMetadata - createIdp owner idpmeta - testSparExternalIdDifferentFromEmail :: (HasCallStack) => App () testSparExternalIdDifferentFromEmail = do (owner, tid, _) <- createTeam OwnDomain 1 - tok <- createScimToken owner >>= \resp -> resp.json %. "token" >>= asString + tok <- createScimTokenV6 owner def >>= \resp -> resp.json %. "token" >>= asString email <- randomEmail extId <- randomExternalId scimUser <- randomScimUserWith extId email @@ -231,7 +225,7 @@ testSparExternalIdDifferentFromEmail = do testSparExternalIdUpdateToANonEmail :: (HasCallStack) => App () testSparExternalIdUpdateToANonEmail = do (owner, tid, _) <- createTeam OwnDomain 1 - tok <- createScimToken owner >>= \resp -> resp.json %. "token" >>= asString + tok <- createScimTokenV6 owner def >>= \resp -> resp.json %. "token" >>= asString scimUser <- randomScimUser >>= removeField "emails" email <- scimUser %. "externalId" >>= asString userId <- bindResponse (createScimUser OwnDomain tok scimUser) $ \resp -> do @@ -247,7 +241,7 @@ testSparExternalIdUpdateToANonEmail = do testSparMigrateFromExternalIdOnlyToEmail :: (HasCallStack) => Tagged "mailUnchanged" Bool -> App () testSparMigrateFromExternalIdOnlyToEmail (MkTagged emailUnchanged) = do (owner, tid, _) <- createTeam OwnDomain 1 - tok <- createScimToken owner >>= \resp -> resp.json %. "token" >>= asString + tok <- createScimTokenV6 owner def >>= \resp -> resp.json %. "token" >>= asString scimUser <- randomScimUser >>= removeField "emails" email <- scimUser %. "externalId" >>= asString userId <- createScimUser OwnDomain tok scimUser >>= getJSON 201 >>= (%. "id") >>= asString @@ -292,11 +286,11 @@ registerUser domain tid email = do activateEmail :: (HasCallStack, MakesValue domain) => domain -> String -> App () activateEmail domain email = do - (key, code) <- bindResponse (BrigInternal.getActivationCode domain email) $ \res -> do + (actkey, code) <- bindResponse (BrigInternal.getActivationCode domain email) $ \res -> do (,) <$> (res.json %. "key" >>= asString) <*> (res.json %. "code" >>= asString) - Brig.activate domain key code >>= assertSuccess + Brig.activate domain actkey code >>= assertSuccess checkSparGetUserAndFindByExtId :: (HasCallStack, MakesValue domain) => domain -> String -> String -> String -> (Value -> App ()) -> App () checkSparGetUserAndFindByExtId domain tok extId uid k = do @@ -315,8 +309,8 @@ checkSparGetUserAndFindByExtId domain tok extId uid k = do testSparCreateScimTokenNoName :: (HasCallStack) => App () testSparCreateScimTokenNoName = do (owner, _tid, mem : _) <- createTeam OwnDomain 2 - createScimToken owner >>= assertSuccess - createScimToken owner >>= assertSuccess + createScimTokenV6 owner def >>= assertSuccess + createScimTokenV6 owner def >>= assertSuccess tokens <- bindResponse (getScimTokens owner) $ \resp -> do resp.status `shouldMatchInt` 200 tokens <- resp.json %. "tokens" >>= asList @@ -334,11 +328,34 @@ testSparCreateScimTokenNoName = do tokenId <- token %. "id" >>= asString token %. "name" `shouldMatch` ("token:" <> tokenId) +-- | in V6, create idp then scim without idp id and idp id is unique +testSparCreateScimTokenAssocImplicitly :: (HasCallStack) => App () +testSparCreateScimTokenAssocImplicitly = do + (owner, tid, _) <- createTeam OwnDomain 1 + void $ setTeamFeatureStatus owner tid "sso" "enabled" + samlIdpId <- bindResponse (registerTestIdPWithMeta owner) $ \resp -> do + resp.status `shouldMatchInt` 201 + resp.json %. "id" + resp2 <- createScimTokenV6 owner def >>= getJSON 200 + resp2 %. "info.idp" `shouldMatch` samlIdpId + +-- | in V6, name should be ignored testSparCreateScimTokenWithName :: (HasCallStack) => App () testSparCreateScimTokenWithName = do (owner, _tid, _) <- createTeam OwnDomain 1 - let expected = "my scim token" - createScimTokenWithName owner expected >>= assertSuccess + let notExpected = "my scim token" + createScimTokenV6 owner (def {name = Just notExpected}) >>= assertSuccess + token <- getScimTokens owner >>= getJSON 200 >>= (%. "tokens") >>= asList >>= assertOne + assoc <- token %. "id" + token %. "name" `shouldMatch` Just assoc + +-- | in V6, create two idps then one scim should fail +testSparCreateTwoScimTokensForOneIdp :: (HasCallStack) => App () +testSparCreateTwoScimTokensForOneIdp = do + (owner, tid, _) <- createTeam OwnDomain 1 + void $ setTeamFeatureStatus owner tid "sso" "enabled" + registerTestIdPWithMeta owner >>= assertSuccess + registerTestIdPWithMeta owner >>= assertSuccess + createScimTokenV6 owner def >>= assertStatus 400 tokens <- getScimTokens owner >>= getJSON 200 >>= (%. "tokens") >>= asList - for_ tokens $ \token -> do - token %. "name" `shouldMatch` expected + length tokens `shouldMatchInt` 0 diff --git a/integration/test/Test/Spar/STM.hs b/integration/test/Test/Spar/STM.hs new file mode 100644 index 00000000000..1b4d184b39b --- /dev/null +++ b/integration/test/Test/Spar/STM.hs @@ -0,0 +1,245 @@ +{-# OPTIONS_GHC -Wno-ambiguous-fields #-} + +-- | State transition machine DSL for running various operations on spar and maintaining +-- internal invariants. At the time of writing this, supported operations on wire teams are: +-- add scim peer, remove scim peer, add saml idp. +-- +-- See the test cases why all the abstractions and boilerplates may be worth it already, but +-- since the DSL embedding is deep, it is also straight-forward to generate random "programs" +-- and thus get property-based integration tests! +module Test.Spar.STM (testCreateIdpsAndScimsV7) where + +import API.Common (defPassword) +import API.GalleyInternal (setTeamFeatureStatus) +import API.Nginz (login) +import API.Spar +import qualified Data.Map as Map +import qualified SAML2.WebSSO as SAML +import SetupHelpers +import Test.Spar +import Testlib.JSON +import Testlib.Prelude +import qualified Text.XML.DSig as SAML + +-- | This is a bit silly, but it allows us to write more straight-forward code and still get +-- better error messages than "something went wrong in your code, please try again". +(!) :: (HasCallStack, Ord k, Show k, Show a) => Map k a -> k -> a +m ! k = case m Map.!? k of + Nothing -> error $ "(!) failed: " <> show (m, k) + Just a -> a + +infixl 9 ! + +-- | Create a few saml IdPs and a few scim peers. Randomize the order in which they are +-- created, and which peers / IdPs they are associated with. +testCreateIdpsAndScimsV7 :: (HasCallStack) => App () +testCreateIdpsAndScimsV7 = do + runSteps + [ MkSaml (SamlRef "saml1") ExpectSuccess + ] + + runSteps + [ -- create a single, unassociated scim. + MkScim (ScimRef "scim1") Nothing ExpectSuccess, + -- create a single, unassociated saml idp. + MkSaml (SamlRef "saml1") ExpectSuccess, + -- new in V7: if there is a saml idp but not referenced in request, do not connect. + MkScim (ScimRef "scim1-solo") Nothing ExpectSuccess, + -- 2 idps with scim is ok now. + MkSaml (SamlRef "saml2") ExpectSuccess, + -- two scims can be associated with one idp + MkScim (ScimRef "scim2") (Just (SamlRef "saml1")) ExpectSuccess, + MkScim (ScimRef "scim3") (Just (SamlRef "saml1")) ExpectSuccess + ] + + -- two saml idps cannot associate with the same scim peer: it would be unclear which idp the + -- next user is supposed to be provisioned for. (not need to test, because it cannot be + -- expressed in the API.) but two scim can connect to the same saml: + runSteps + [ MkSaml (SamlRef "saml1") ExpectSuccess, + MkScim (ScimRef "scim1") (Just (SamlRef "saml1")) ExpectSuccess, + RmScim (ScimRef "scim1"), + MkScim (ScimRef "scim2") (Just (SamlRef "saml1")) ExpectSuccess + ] + +-- | DSL with relevant api calls (not test cases). This should make writing down different +-- test cases very concise and not cost any generality. +data Step + = MkScim ScimRef (Maybe SamlRef) ExpectedResult + | -- | `RmScim` has expected result: delete is idempotent. + RmScim ScimRef + | -- | you can't associate a saml idp with a existing scim peer when creating the idp. + -- do that by replacing the scim token and associating the new one during creation. + MkSaml SamlRef ExpectedResult + deriving (Show) + +data ExpectedResult = ExpectSuccess | ExpectFailure Int String + deriving (Eq, Show, Generic) + +data State = State + { allIdps :: Map SamlRef SamlId, + allIdpCredsById :: Map SamlId (SAML.IdPMetadata, SAML.SignPrivCreds), + allScims :: Map ScimRef (ScimId, ScimToken), + allScimAssocs :: Map ScimId SamlId + } + deriving (Eq, Show) + +emptyState :: State +emptyState = State mempty mempty mempty mempty + +-- (SamlName) +newtype SamlRef = SamlRef {_unSamlRef :: String} + deriving newtype (Eq, Show, Ord, ToJSON) + +-- (ScimName) +newtype ScimRef = ScimRef {unScimRef :: String} + deriving newtype (Eq, Show, Ord, ToJSON) + +-- (UUID) +newtype SamlId = SamlId {unSamlId :: String} + deriving newtype (Eq, Show, Ord, ToJSON) + +-- (UUID) +newtype ScimId = ScimId {unScimId :: String} + deriving newtype (Eq, Show, Ord, ToJSON, ToJSONKey) + +-- (for auth) +newtype ScimToken = ScimToken {unScimToken :: String} + deriving newtype (Eq, Show, Ord, ToJSON) + +runSteps :: (HasCallStack) => [Step] -> App () +runSteps steps = do + (owner, tid, []) <- createTeam OwnDomain 1 + void $ setTeamFeatureStatus owner tid "sso" "enabled" + go owner tid emptyState steps + where + go :: Value -> String -> State -> [Step] -> App () + go _ _ _ [] = pure () + -- add scim + go owner tid state (next@(MkScim scimRef mbSamlRef expected) : steps') = addFailureContext (show next) do + let mIdPId = (state.allIdps !) <$> mbSamlRef + let p = def {name = Just (unScimRef scimRef), idp = unSamlId <$> mIdPId} + state' <- bindResponse (createScimToken owner p) $ \resp -> do + case expected of + ExpectSuccess -> validateScimRegistration state scimRef mIdPId resp + ExpectFailure errStatus errLabel -> validateError resp errStatus errLabel $> state + validateState owner tid state' + go owner tid state' steps' + -- add saml + go owner tid state (next@(MkSaml samlRef expected) : steps') = addFailureContext (show next) do + state' <- do + (resp, creds) <- registerTestIdPWithMetaWithPrivateCreds owner + case expected of + ExpectSuccess -> validateSamlRegistration state samlRef resp creds + ExpectFailure errStatus errLabel -> validateError resp errStatus errLabel $> state + validateState owner tid state' + go owner tid state' steps' + -- remove scim + go owner tid state (next@(RmScim scimRef) : steps') = addFailureContext (show next) do + let (scimId, _) = state.allScims ! scimRef + state' <- bindResponse (deleteScimToken owner (unScimId scimId)) $ \resp -> do + resp.status `shouldMatchInt` 204 + pure + $ state + { allScims = Map.delete scimRef (allScims state), + allScimAssocs = Map.delete scimId (allScimAssocs state) + } + validateState owner tid state' + go owner tid state' steps' + +validateScimRegistration :: State -> ScimRef -> Maybe SamlId -> Response -> App State +validateScimRegistration state scimRef mIdPId resp = do + resp.status `shouldMatchInt` 200 + scimId <- resp.json %. "info.id" >>= asString + tok <- resp.json %. "token" >>= asString + pure + $ state + { allScims = Map.insert scimRef (ScimId scimId, ScimToken tok) (allScims state), + allScimAssocs = maybe id (Map.insert (ScimId scimId)) mIdPId $ allScimAssocs state + } + +validateSamlRegistration :: State -> SamlRef -> Response -> (SAML.IdPMetadata, SAML.SignPrivCreds) -> App State +validateSamlRegistration state samlRef resp creds = do + resp.status `shouldMatchInt` 201 + samlId <- resp.json %. "id" >>= asString + pure + $ state + { allIdps = Map.insert samlRef (SamlId samlId) state.allIdps, + allIdpCredsById = Map.insert (SamlId samlId) creds state.allIdpCredsById + } + +validateState :: Value -> String -> State -> App () +validateState owner tid state = do + allIdps <- getIdps owner >>= getJSON 200 >>= (%. "providers") >>= asList + allScims <- getScimTokens owner >>= getJSON 200 >>= (%. "tokens") >>= asList + + validateStateSyncTestAndProdIdps state allIdps + validateStateSyncTestAndProdScims state allScims + validateStateSyncTestAndProdAssocs state allScims + validateStateLoginAllUsers owner tid state + +-- | are all idps from spar in the local test state and vice versa? +validateStateSyncTestAndProdIdps :: State -> [Value] -> App () +validateStateSyncTestAndProdIdps state allIdps = do + let allLocal = Map.elems state.allIdps + allSpar <- ((%. "id") >=> asString) `traverse` allIdps + allLocal `shouldMatchSet` allSpar + +-- | are all scim peers from spar in the local test state and vice versa? +validateStateSyncTestAndProdScims :: State -> [Value] -> App () +validateStateSyncTestAndProdScims state allScims = do + let allLocal = fst <$> Map.elems state.allScims + allSpar <- (%. "id") `traverse` allScims + allLocal `shouldMatchSet` allSpar + +-- | are all local associations the same as on spar? +validateStateSyncTestAndProdAssocs :: State -> [Value] -> App () +validateStateSyncTestAndProdAssocs state allScims = do + let toScimIdpPair tokInfo = do + mIdp <- lookupField tokInfo "idp" + case mIdp of + Just idp -> Just <$> ((,) <$> (tokInfo %. "id" >>= asString) <*> asString idp) + Nothing -> pure Nothing + + sparState <- Map.fromList . catMaybes <$> (toScimIdpPair `mapM` allScims) + sparState `shouldMatch` state.allScimAssocs + +-- | login. (auto-provisioning with saml without scim is intentionally not tested.) +-- (performance: only login users that have just been created, so that throughout a `[Step]`, +-- every user is only logged in once.) +validateStateLoginAllUsers :: Value -> String -> State -> App () +validateStateLoginAllUsers owner tid state = do + for_ (Map.elems state.allScims) $ \(scimId, tok) -> do + let mIdp :: Maybe (String {- id -}, (SAML.IdPMetadata, SAML.SignPrivCreds)) + mIdp = do + i <- Map.lookup scimId state.allScimAssocs + c <- Map.lookup i state.allIdpCredsById + pure (unSamlId i, c) + + scimUser <- randomScimUser + email <- scimUser %. "externalId" >>= asString + uid <- bindResponse (createScimUser owner (unScimToken tok) scimUser) $ \resp -> do + resp.status `shouldMatchInt` 201 + resp.json %. "id" >>= asString + when (isNothing mIdp) $ do + registerUser OwnDomain tid email + + maybe (loginWithPassword 200 scimUser) (loginWithSaml True tid scimUser) mIdp + + bindResponse (deleteScimUser owner (unScimToken tok) uid) $ \resp -> do + resp.status `shouldMatchInt` 204 + + maybe (loginWithPassword 403 scimUser) (loginWithSaml False tid scimUser) mIdp + +validateError :: Response -> Int -> String -> App () +validateError resp errStatus errLabel = do + do + resp.status `shouldMatchInt` errStatus + resp.json %. "code" `shouldMatchInt` errStatus + resp.json %. "label" `shouldMatch` errLabel + +loginWithPassword :: (HasCallStack) => Int -> Value -> App () +loginWithPassword expectedStatus scimUser = do + email <- scimUser %. "emails" >>= asList >>= assertOne >>= (%. "value") >>= asString + bindResponse (login OwnDomain email defPassword) $ \resp -> do + resp.status `shouldMatchInt` expectedStatus diff --git a/integration/test/Test/Swagger.hs b/integration/test/Test/Swagger.hs index 571bd1ab245..a84fc7b5419 100644 --- a/integration/test/Test/Swagger.hs +++ b/integration/test/Test/Swagger.hs @@ -13,7 +13,7 @@ import Testlib.Prelude import UnliftIO.Temporary existingVersions :: Set Int -existingVersions = Set.fromList [0, 1, 2, 3, 4, 5, 6, 7] +existingVersions = Set.fromList [0, 1, 2, 3, 4, 5, 6, 7, 8] internalApis :: Set String internalApis = Set.fromList ["brig", "cannon", "cargohold", "cannon", "spar"] @@ -76,16 +76,12 @@ testSwaggerToc = do bindResponse (get path) $ \resp -> do resp.status `shouldMatchInt` 200 let body = cs @_ @String resp.body - body `shouldMatch` html forM_ existingVersions $ \v -> - body `shouldContainString` ("v" <> show v) + body `shouldContainString` ("\nv" <> show v <> ":") where get :: String -> App Response get path = rawBaseRequest OwnDomain Brig Unversioned path >>= submit "GET" - html :: String - html = "<html><head></head><body><h1>OpenAPI 3.0 docs for all Wire APIs</h1>\n<p>This wire-server system provides <a href=\"https://swagger.io/resources/open-api/\">OpenAPI 3.0</a> documentation of our HTTP REST API.</p> <p>The openapi docs are correct by construction (compiled from the server code), and more or less complete.</p> <p>Some endpoints are version-controlled. </a href=\"/api-version\">Show all supported versions.</a> <a href=\"https://docs.wire.com/developer/developer/api-versioning.html\">find out more.</a>\n<h2>Public (all available versions)</h2>\nv0: \n<a href=\"/v0/api/swagger-ui\">swagger-ui</a>; \n<a href=\"/v0/api/swagger.json\">swagger.json</a>\n<br>\nv1: \n<a href=\"/v1/api/swagger-ui\">swagger-ui</a>; \n<a href=\"/v1/api/swagger.json\">swagger.json</a>\n<br>\nv2: \n<a href=\"/v2/api/swagger-ui\">swagger-ui</a>; \n<a href=\"/v2/api/swagger.json\">swagger.json</a>\n<br>\nv3: \n<a href=\"/v3/api/swagger-ui\">swagger-ui</a>; \n<a href=\"/v3/api/swagger.json\">swagger.json</a>\n<br>\nv4: \n<a href=\"/v4/api/swagger-ui\">swagger-ui</a>; \n<a href=\"/v4/api/swagger.json\">swagger.json</a>\n<br>\nv5: \n<a href=\"/v5/api/swagger-ui\">swagger-ui</a>; \n<a href=\"/v5/api/swagger.json\">swagger.json</a>\n<br>\nv6: \n<a href=\"/v6/api/swagger-ui\">swagger-ui</a>; \n<a href=\"/v6/api/swagger.json\">swagger.json</a>\n<br>\nv7: \n<a href=\"/v7/api/swagger-ui\">swagger-ui</a>; \n<a href=\"/v7/api/swagger.json\">swagger.json</a>\n<br>\n\n<h2>Internal (not versioned)</h2>\n<p>Openapi docs for internal endpoints are served per service. I.e. there's one for `brig`, one for `cannon`, etc.. This is because Openapi doesn't play well with multiple actions having the same combination of HTTP method and URL path.</p>\nbrig:<br>\n<a href=\"/api-internal/swagger-ui/brig\">swagger-ui</a>; \n<a href=\"/api-internal/swagger-ui/brig-swagger.json\">swagger.json</a>\n<br>\ngalley:<br>\n<a href=\"/api-internal/swagger-ui/galley\">swagger-ui</a>; \n<a href=\"/api-internal/swagger-ui/galley-swagger.json\">swagger.json</a>\n<br>\nspar:<br>\n<a href=\"/api-internal/swagger-ui/spar\">swagger-ui</a>; \n<a href=\"/api-internal/swagger-ui/spar-swagger.json\">swagger.json</a>\n<br>\ncargohold:<br>\n<a href=\"/api-internal/swagger-ui/cargohold\">swagger-ui</a>; \n<a href=\"/api-internal/swagger-ui/cargohold-swagger.json\">swagger.json</a>\n<br>\ngundeck:<br>\n<a href=\"/api-internal/swagger-ui/gundeck\">swagger-ui</a>; \n<a href=\"/api-internal/swagger-ui/gundeck-swagger.json\">swagger.json</a>\n<br>\ncannon:<br>\n<a href=\"/api-internal/swagger-ui/cannon\">swagger-ui</a>; \n<a href=\"/api-internal/swagger-ui/cannon-swagger.json\">swagger.json</a>\n<br>\nproxy:<br>\n<a href=\"/api-internal/swagger-ui/proxy\">swagger-ui</a>; \n<a href=\"/api-internal/swagger-ui/proxy-swagger.json\">swagger.json</a>\n<br>\n\n<h2>Federated API (backend-to-backend)</h2>\nbrig (v0):<br><a href=\"/v0/api-federation/swagger-ui/brig\">swagger-ui</a>; <a href=\"/v0/api-federation/swagger-ui/brig-swagger.json\">swagger.json</a><br>brig (v1):<br><a href=\"/v1/api-federation/swagger-ui/brig\">swagger-ui</a>; <a href=\"/v1/api-federation/swagger-ui/brig-swagger.json\">swagger.json</a><br>brig (v2):<br><a href=\"/v2/api-federation/swagger-ui/brig\">swagger-ui</a>; <a href=\"/v2/api-federation/swagger-ui/brig-swagger.json\">swagger.json</a><br><br>\ngalley (v0):<br><a href=\"/v0/api-federation/swagger-ui/galley\">swagger-ui</a>; <a href=\"/v0/api-federation/swagger-ui/galley-swagger.json\">swagger.json</a><br>galley (v1):<br><a href=\"/v1/api-federation/swagger-ui/galley\">swagger-ui</a>; <a href=\"/v1/api-federation/swagger-ui/galley-swagger.json\">swagger.json</a><br>galley (v2):<br><a href=\"/v2/api-federation/swagger-ui/galley\">swagger-ui</a>; <a href=\"/v2/api-federation/swagger-ui/galley-swagger.json\">swagger.json</a><br><br>\ncargohold (v0):<br><a href=\"/v0/api-federation/swagger-ui/cargohold\">swagger-ui</a>; <a href=\"/v0/api-federation/swagger-ui/cargohold-swagger.json\">swagger.json</a><br>cargohold (v1):<br><a href=\"/v1/api-federation/swagger-ui/cargohold\">swagger-ui</a>; <a href=\"/v1/api-federation/swagger-ui/cargohold-swagger.json\">swagger.json</a><br>cargohold (v2):<br><a href=\"/v2/api-federation/swagger-ui/cargohold\">swagger-ui</a>; <a href=\"/v2/api-federation/swagger-ui/cargohold-swagger.json\">swagger.json</a><br><br>\n\n</body></html>\n" - data Swagger = SwaggerPublic | SwaggerInternal Service instance TestCases Swagger where diff --git a/integration/test/Test/Teams.hs b/integration/test/Test/Teams.hs index 5ce10031fba..2d566527c0e 100644 --- a/integration/test/Test/Teams.hs +++ b/integration/test/Test/Teams.hs @@ -24,6 +24,7 @@ import API.Common import API.Galley (getTeam, getTeamMembers, getTeamMembersCsv, getTeamNotifications) import API.GalleyInternal (setTeamFeatureStatus) import API.Gundeck +import qualified API.Nginz as Nginz import Control.Monad.Codensity (Codensity (runCodensity)) import Control.Monad.Extra (findM) import Control.Monad.Reader (asks) @@ -66,6 +67,10 @@ testInvitePersonalUserToTeam = do checkListInvitations owner tid email code <- I.getInvitationCode owner inv >>= getJSON 200 >>= (%. "code") & asString inv %. "url" & asString >>= assertUrlContainsCode code + bindResponse (getInvitationByCode user code) $ \resp -> do + resp.status `shouldMatchInt` 200 + ownersEmail <- owner %. "email" & asString + resp.json %. "created_by_email" `shouldMatch` ownersEmail acceptTeamInvitation user code Nothing >>= assertStatus 400 acceptTeamInvitation user code (Just "wrong-password") >>= assertStatus 403 @@ -122,7 +127,11 @@ testInvitePersonalUserToTeam = do checkListInvitations :: Value -> String -> String -> App () checkListInvitations owner tid email = do newUserEmail <- randomEmail - void $ postInvitation owner (PostInvitation (Just newUserEmail) Nothing) >>= assertSuccess + inv <- postInvitation owner (PostInvitation (Just newUserEmail) Nothing) >>= getJSON 201 + code <- I.getInvitationCode owner inv >>= getJSON 200 >>= (%. "code") & asString + bindResponse (getInvitationByCode owner code) $ \resp -> do + resp.status `shouldMatchInt` 200 + lookupField resp.json "created_by_email" `shouldMatch` (Nothing :: Maybe Value) bindResponse (listInvitations owner tid) $ \resp -> do resp.status `shouldMatchInt` 200 invitations <- resp.json %. "invitations" >>= asList @@ -156,8 +165,8 @@ testInvitePersonalUserToLargeTeam = do traverse_ (connectTwoUsers knut) [alice, dawn, eli] addFailureContext ("tid: " <> tid) $ do - uidContext <- mkContextUserIds [("owner", owner), ("alice", alice), ("knut", knut), ("dawn", dawn), ("eli", eli)] - addFailureContext uidContext $ do + let uids = [("owner", owner), ("alice", alice), ("knut", knut), ("dawn", dawn), ("eli", eli)] + addUsersToFailureContext uids $ do lastTeamNotif <- getTeamNotifications owner Nothing `bindResponse` \resp -> do resp.status `shouldMatchInt` 200 @@ -196,16 +205,6 @@ testInvitePersonalUserToLargeTeam = do resp.json %. "notifications.1.payload.0.team" `shouldMatch` tid resp.json %. "notifications.1.payload.0.data.user" `shouldMatch` objId knut -mkContextUserIds :: (MakesValue user) => [(String, user)] -> App String -mkContextUserIds = - fmap (intercalate "\n") - . traverse - ( \(name, user) -> do - uid <- objQidObject user %. "id" & asString - domain <- objDomain user - pure $ name <> ": " <> uid <> "@" <> domain - ) - testInvitePersonalUserToTeamMultipleInvitations :: (HasCallStack) => App () testInvitePersonalUserToTeamMultipleInvitations = do (owner, tid, _) <- createTeam OwnDomain 0 @@ -228,6 +227,26 @@ testInvitePersonalUserToTeamMultipleInvitations = do resp.json %. "team" `shouldMatch` tid acceptTeamInvitation user code (Just defPassword) >>= assertStatus 400 +testInvitePersonalUserToTeamLegacy :: (HasCallStack) => App () +testInvitePersonalUserToTeamLegacy = withAPIVersion 6 $ do + (owner, tid, _) <- createTeam OwnDomain 0 + user <- I.createUser OwnDomain def >>= getJSON 201 + + -- inviting an existing user should fail + do + email <- user %. "email" >>= asString + bindResponse (postInvitation owner (PostInvitation (Just email) Nothing)) $ \resp -> do + resp.status `shouldMatchInt` 409 + resp.json %. "label" `shouldMatch` "email-exists" + + -- inviting a new user should succeed + do + email <- randomEmail + bindResponse (postInvitation owner (PostInvitation (Just email) Nothing)) $ \resp -> do + resp.status `shouldMatchInt` 201 + resp.json %. "email" `shouldMatch` email + resp.json %. "team" `shouldMatch` tid + testInvitationTypesAreDistinct :: (HasCallStack) => App () testInvitationTypesAreDistinct = do -- We are only testing one direction because the other is not possible @@ -256,8 +275,10 @@ testTeamUserCannotBeInvited = do testUpgradePersonalToTeam :: (HasCallStack) => App () testUpgradePersonalToTeam = do alice <- randomUser OwnDomain def + email <- alice %. "email" >>= asString let teamName = "wonderland" - tid <- bindResponse (upgradePersonalToTeam alice teamName) $ \resp -> do + token <- Nginz.login OwnDomain email defPassword >>= getJSON 200 >>= (%. "access_token") & asString + tid <- bindResponse (Nginz.upgradePersonalToTeam alice token teamName) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "team_name" `shouldMatch` teamName resp.json %. "team_id" @@ -275,6 +296,16 @@ testUpgradePersonalToTeam = do shouldBeNull $ owner %. "created_at" shouldBeNull $ owner %. "created_by" + mem <- createTeamMember alice' def + I.refreshIndex OwnDomain + + bindResponse (searchTeamAll alice') $ \resp -> do + resp.status `shouldMatchInt` 200 + docs <- resp.json %. "documents" >>= asList + actualIds <- for docs ((%. "id") >=> asString) + expectedIds <- for [alice', mem] ((%. "id") >=> asString) + actualIds `shouldMatchSet` expectedIds + testUpgradePersonalToTeamAlreadyInATeam :: (HasCallStack) => App () testUpgradePersonalToTeamAlreadyInATeam = do (alice, _, _) <- createTeam OwnDomain 0 diff --git a/integration/test/Testlib/App.hs b/integration/test/Testlib/App.hs index 2eecee9d686..66caf35d070 100644 --- a/integration/test/Testlib/App.hs +++ b/integration/test/Testlib/App.hs @@ -12,14 +12,14 @@ import qualified Data.Text as T import qualified Data.Yaml as Yaml import GHC.Exception import GHC.Generics (Generic) -import GHC.Stack (HasCallStack) +import GHC.Stack (HasCallStack, callStack) import System.FilePath import Testlib.JSON import Testlib.Types import Prelude failApp :: (HasCallStack) => String -> App a -failApp msg = throw (AppFailure msg) +failApp msg = throw (AppFailure msg callStack) getPrekey :: App Value getPrekey = App $ do diff --git a/integration/test/Testlib/Assertions.hs b/integration/test/Testlib/Assertions.hs index 28ddf0c0af1..8ab015156f9 100644 --- a/integration/test/Testlib/Assertions.hs +++ b/integration/test/Testlib/Assertions.hs @@ -202,6 +202,8 @@ shouldMatchRange a (lower, upper) = do pa <- prettyJSON xa assertFailure $ "Actual:\n" <> pa <> "\nExpected:\nin range (" <> show lower <> ", " <> show upper <> ") (including bounds)" +-- | Match on sorted lists (sets where elements may occur more than once). (Maybe this should +-- be called `shouldMatchMultiSet`?) shouldMatchSet :: (MakesValue a, MakesValue b, HasCallStack) => a -> @@ -261,6 +263,15 @@ printFailureDetails (AssertionFailure stack mbResponse ctx msg) = do : toList (fmap prettyResponse mbResponse) <> toList (fmap prettyContext ctx) +printAppFailureDetails :: AppFailure -> IO String +printAppFailureDetails (AppFailure msg stack) = do + s <- prettierCallStack stack + pure . unlines $ + colored yellow "app failure:" + : colored red msg + : "\n" + : [s] + prettyContext :: String -> String prettyContext ctx = do unlines diff --git a/integration/test/Testlib/Env.hs b/integration/test/Testlib/Env.hs index b5611178b6f..2a9d3f8dafd 100644 --- a/integration/test/Testlib/Env.hs +++ b/integration/test/Testlib/Env.hs @@ -5,7 +5,6 @@ module Testlib.Env where import Control.Monad.Codensity import Control.Monad.IO.Class import Control.Monad.Reader -import Data.Default import Data.Function ((&)) import Data.Functor import Data.IORef @@ -14,6 +13,7 @@ import Data.Maybe (fromMaybe) import Data.Traversable (for) import qualified Data.Yaml as Yaml import qualified Database.CQL.IO as Cassandra +import GHC.Stack import qualified Network.HTTP.Client as HTTP import qualified OpenSSL.Session as OpenSSL import System.Directory @@ -103,7 +103,7 @@ mkGlobalEnv cfgFile = do gFederationV0Domain = intConfig.federationV0.originDomain, gFederationV1Domain = intConfig.federationV1.originDomain, gDynamicDomains = (.domain) <$> Map.elems intConfig.dynamicBackends, - gDefaultAPIVersion = 7, + gDefaultAPIVersion = 8, gManager = manager, gServicesCwdBase = devEnvProjectRoot <&> (</> "services"), gBackendResourcePool = resourcePool, @@ -171,15 +171,17 @@ mkMLSState = Codensity $ \k -> k MLSState { baseDir = tmp, - members = mempty, - newMembers = mempty, - groupId = Nothing, - convId = Nothing, - clientGroupState = mempty, - epoch = 0, - ciphersuite = def, - protocol = MLSProtocolMLS + convs = mempty, + clientGroupState = mempty } +getMLSConv :: (HasCallStack) => ConvId -> App MLSConv +getMLSConv convId = do + mConv <- Map.lookup convId . (.convs) <$> getMLSState + case mConv of + Just conv -> pure conv + Nothing -> do + assertFailure $ "MLSConv not found, convId=" <> show convId + withAPIVersion :: Int -> App a -> App a withAPIVersion v = local $ \e -> e {defaultAPIVersion = v} diff --git a/integration/test/Testlib/JSON.hs b/integration/test/Testlib/JSON.hs index 96ee6da2492..5169ea9a6b1 100644 --- a/integration/test/Testlib/JSON.hs +++ b/integration/test/Testlib/JSON.hs @@ -211,7 +211,7 @@ lookupFieldM = fmap MaybeT . lookupField -- If the field key has no dots then returns Nothing if the key is missing from the -- object. -- --- If the field key has dots (describes a nested lookuyp) then returns Nothing +-- If the field key has dots (describes a nested lookup) then returns Nothing -- if the last component of the key field selector is missing from nested -- object. If any other component is missing this function raises an -- AssertionFailure. @@ -436,16 +436,16 @@ objSubConv x = do lift $ asString sub' pure (obj, sub) --- | Turn an object parseable by 'objSubConv' into a canonical flat representation. -objSubConvObject :: (HasCallStack, MakesValue a) => a -> App Value -objSubConvObject x = do - (convId, mSubConvId) <- objSubConv x - (domain, id_) <- objQid convId - pure . object $ - [ "domain" .= domain, - "id" .= id_ - ] - <> ["subconv_id" .= sub | sub <- toList mSubConvId] +objConvId :: (HasCallStack, MakesValue conv) => conv -> App ConvId +objConvId conv = do + v <- make conv + -- Domain and ConvId either come from parent_qualified_id or qualified_id + mParent <- lookupField v "parent_qualified_id" + (domain, id_) <- objQid $ fromMaybe v mParent + + groupId <- traverse asString =<< asOptional (lookupField v "group_id") + subconvId <- traverse asString =<< asOptional (lookupField v "subconv_id") + pure ConvId {..} instance MakesValue ClientIdentity where make cid = diff --git a/integration/test/Testlib/ModService.hs b/integration/test/Testlib/ModService.hs index 379547c4d2b..a41244faaae 100644 --- a/integration/test/Testlib/ModService.hs +++ b/integration/test/Testlib/ModService.hs @@ -11,7 +11,7 @@ where import Control.Concurrent import Control.Concurrent.Async import qualified Control.Exception as E -import Control.Monad.Catch (catch, throwM) +import Control.Monad.Catch (catch, displayException, throwM) import Control.Monad.Codensity import Control.Monad.Extra import Control.Monad.Reader @@ -38,6 +38,7 @@ import System.IO.Temp (createTempDirectory, writeTempFile) import System.Posix (keyboardSignal, killProcess, signalProcess) import System.Process import Testlib.App +import Testlib.Assertions (prettierCallStack) import Testlib.HTTP import Testlib.JSON import Testlib.Printing @@ -118,20 +119,19 @@ traverseConcurrentlyCodensity f args = do pure result startDynamicBackends :: [ServiceOverrides] -> ([String] -> App a) -> App a -startDynamicBackends beOverrides k = - runCodensity - do - when (Prelude.length beOverrides > 3) $ lift $ failApp "Too many backends. Currently only 3 are supported." - pool <- asks (.resourcePool) - resources <- acquireResources (Prelude.length beOverrides) pool - void $ - traverseConcurrentlyCodensity - (void . uncurry startDynamicBackend) - (zip resources beOverrides) - pure $ map (.berDomain) resources - k - -startDynamicBackend :: BackendResource -> ServiceOverrides -> Codensity App String +startDynamicBackends beOverrides k = do + let startDynamicBackendsCodensity = do + when (Prelude.length beOverrides > 3) $ lift $ failApp "Too many backends. Currently only 3 are supported." + pool <- asks (.resourcePool) + resources <- acquireResources (Prelude.length beOverrides) pool + void $ + traverseConcurrentlyCodensity + (void . uncurry startDynamicBackend) + (zip resources beOverrides) + pure $ map (.berDomain) resources + runCodensity startDynamicBackendsCodensity k + +startDynamicBackend :: (HasCallStack) => BackendResource -> ServiceOverrides -> Codensity App String startDynamicBackend resource beOverrides = do let overrides = mconcat @@ -179,7 +179,9 @@ startDynamicBackend resource beOverrides = do >=> setField "federator.host" ("127.0.0.1" :: String) >=> setField "federator.port" resource.berFederatorInternal >=> setField "rabbitmq.vHost" resource.berVHost, - gundeckCfg = setField "settings.federationDomain" resource.berDomain, + gundeckCfg = + setField "settings.federationDomain" resource.berDomain + >=> setField "rabbitmq.vHost" resource.berVHost, backgroundWorkerCfg = setField "federatorInternal.port" resource.berFederatorInternal >=> setField "federatorInternal.host" ("127.0.0.1" :: String) @@ -187,7 +189,9 @@ startDynamicBackend resource beOverrides = do federatorInternalCfg = setField "federatorInternal.port" resource.berFederatorInternal >=> setField "federatorExternal.port" resource.berFederatorExternal - >=> setField "optSettings.setFederationDomain" resource.berDomain + >=> setField "optSettings.setFederationDomain" resource.berDomain, + cannonCfg = + setField "rabbitmq.vHost" resource.berVHost } setKeyspace :: ServiceOverrides @@ -261,10 +265,11 @@ startBackend resource overrides = do traverseConcurrentlyCodensity (withProcess resource overrides) allServices lift $ ensureBackendReachable resource.berDomain -ensureBackendReachable :: String -> App () +ensureBackendReachable :: (HasCallStack) => String -> App () ensureBackendReachable domain = do env <- ask - let checkServiceIsUpReq = do + let checkServiceIsUpReq :: (HasCallStack) => App Bool + checkServiceIsUpReq = do req <- rawBaseRequest env.domain1 @@ -314,9 +319,12 @@ data ServiceInstance = ServiceInstance timeout :: Int -> IO a -> IO (Maybe a) timeout usecs action = either (const Nothing) Just <$> race (threadDelay usecs) action -cleanupService :: ServiceInstance -> IO () +cleanupService :: (HasCallStack) => ServiceInstance -> IO () cleanupService inst = do - let ignoreExceptions action = E.catch action $ \(_ :: E.SomeException) -> pure () + let ignoreExceptions :: (HasCallStack) => IO () -> IO () + ignoreExceptions action = E.catch action $ \(e :: E.SomeException) -> do + callstackPretty <- prettierCallStack callStack + putStrLn $ colored red $ "Exception while cleaning up a service: " <> displayException e <> "\ncallstack: \n" <> callstackPretty ignoreExceptions $ do mPid <- getPid inst.handle for_ mPid (signalProcess keyboardSignal) @@ -329,7 +337,7 @@ cleanupService inst = do whenM (doesDirectoryExist inst.config) $ removeDirectoryRecursive inst.config -- | Wait for a service to come up. -waitUntilServiceIsUp :: String -> Service -> App () +waitUntilServiceIsUp :: (HasCallStack) => String -> Service -> App () waitUntilServiceIsUp domain srv = retryRequestUntil (checkServiceIsUp domain srv) @@ -346,7 +354,7 @@ checkServiceIsUp domain srv = do eith <- liftIO (E.try checkStatus) pure $ either (\(_e :: HTTP.HttpException) -> False) id eith -withProcess :: BackendResource -> ServiceOverrides -> Service -> Codensity App () +withProcess :: (HasCallStack) => BackendResource -> ServiceOverrides -> Service -> Codensity App () withProcess resource overrides service = do let domain = berDomain resource sm <- lift $ getServiceMap domain @@ -393,7 +401,7 @@ logToConsole colorize prefix hdl = do `E.catch` (\(_ :: E.IOException) -> pure ()) go -retryRequestUntil :: (HasCallStack) => App Bool -> String -> App () +retryRequestUntil :: (HasCallStack) => ((HasCallStack) => App Bool) -> String -> App () retryRequestUntil reqAction err = do isUp <- retrying diff --git a/integration/test/Testlib/ResourcePool.hs b/integration/test/Testlib/ResourcePool.hs index aa518939fef..83bd1499a84 100644 --- a/integration/test/Testlib/ResourcePool.hs +++ b/integration/test/Testlib/ResourcePool.hs @@ -89,7 +89,7 @@ deleteAllRabbitMQQueues rc resource = do tls = Just $ RabbitMqTlsOpts Nothing True } client <- mkRabbitMqAdminClientEnv opts - queues <- listQueuesByVHost client (T.pack resource.berVHost) + queues <- listQueuesByVHost client (T.pack resource.berVHost) Nothing Nothing for_ queues $ \queue -> deleteQueue client (T.pack resource.berVHost) queue.name diff --git a/integration/test/Testlib/Run.hs b/integration/test/Testlib/Run.hs index d5385a16376..29a501c03da 100644 --- a/integration/test/Testlib/Run.hs +++ b/integration/test/Testlib/Run.hs @@ -37,6 +37,8 @@ runTest ge action = lowerCodensity $ do E.throw e, E.Handler -- AssertionFailure (fmap Left . printFailureDetails), + E.Handler -- AppFailure + (fmap Left . printAppFailureDetails), E.Handler (fmap Left . printExceptionDetails) ] diff --git a/integration/test/Testlib/Types.hs b/integration/test/Testlib/Types.hs index e25b33d06f8..f7f20c36782 100644 --- a/integration/test/Testlib/Types.hs +++ b/integration/test/Testlib/Types.hs @@ -252,7 +252,7 @@ instance Default Ciphersuite where def = Ciphersuite "0x0001" data ClientGroupState = ClientGroupState - { group :: Maybe ByteString, + { groups :: Map ConvId ByteString, -- | mls-test-cli stores by signature scheme keystore :: Map String ByteString, credType :: CredentialType @@ -262,7 +262,7 @@ data ClientGroupState = ClientGroupState instance Default ClientGroupState where def = ClientGroupState - { group = Nothing, + { groups = mempty, keystore = mempty, credType = BasicCredentialType } @@ -277,17 +277,32 @@ csSignatureScheme (Ciphersuite code) = case code of data MLSProtocol = MLSProtocolMLS | MLSProtocolMixed deriving (Eq, Show) +data ConvId = ConvId + { domain :: String, + id_ :: String, + groupId :: Maybe String, + subconvId :: Maybe String + } + deriving (Show, Eq, Ord) + +convIdToQidObject :: ConvId -> Value +convIdToQidObject convId = object [fromString "id" .= convId.id_, fromString "domain" .= convId.domain] + data MLSState = MLSState { baseDir :: FilePath, - members :: Set ClientIdentity, + convs :: Map ConvId MLSConv, + clientGroupState :: Map ClientIdentity ClientGroupState + } + deriving (Show) + +data MLSConv = MLSConv + { members :: Set ClientIdentity, -- | users expected to receive a welcome message after the next commit newMembers :: Set ClientIdentity, - groupId :: Maybe String, - convId :: Maybe Value, - clientGroupState :: Map ClientIdentity ClientGroupState, + groupId :: String, + convId :: ConvId, epoch :: Word64, - ciphersuite :: Ciphersuite, - protocol :: MLSProtocol + ciphersuite :: Ciphersuite } deriving (Show) @@ -364,11 +379,6 @@ getMLSState = do ref <- asks (.mls) liftIO $ readIORef ref -setMLSState :: MLSState -> App () -setMLSState s = do - ref <- asks (.mls) - liftIO $ writeIORef ref s - modifyMLSState :: (MLSState -> MLSState) -> App () modifyMLSState f = do ref <- asks (.mls) @@ -377,13 +387,13 @@ modifyMLSState f = do getBaseDir :: App FilePath getBaseDir = fmap (.baseDir) getMLSState -data AppFailure = AppFailure String +data AppFailure = AppFailure String CallStack instance Show AppFailure where - show (AppFailure msg) = msg + show (AppFailure msg _) = msg instance Exception AppFailure where - displayException (AppFailure msg) = msg + displayException (AppFailure msg _) = msg instance MonadFail App where fail msg = assertFailure ("Pattern matching failure: " <> msg) diff --git a/libs/extended/src/Network/AMQP/Extended.hs b/libs/extended/src/Network/AMQP/Extended.hs index 955e54c0a33..1453f3909e4 100644 --- a/libs/extended/src/Network/AMQP/Extended.hs +++ b/libs/extended/src/Network/AMQP/Extended.hs @@ -4,11 +4,15 @@ module Network.AMQP.Extended ( RabbitMqHooks (..), RabbitMqAdminOpts (..), AmqpEndpoint (..), + withConnection, openConnectionWithRetries, mkRabbitMqAdminClientEnv, mkRabbitMqChannelMVar, demoteOpts, RabbitMqTlsOpts (..), + mkConnectionOpts, + mkTLSSettings, + readCredsFromEnv, ) where @@ -55,7 +59,7 @@ data RabbitMqTlsOpts = RabbitMqTlsOpts { caCert :: !(Maybe FilePath), insecureSkipVerifyTls :: Bool } - deriving (Show) + deriving (Eq, Show) parseTlsJson :: Object -> Parser (Maybe RabbitMqTlsOpts) parseTlsJson v = do @@ -76,7 +80,7 @@ data RabbitMqAdminOpts = RabbitMqAdminOpts tls :: Maybe RabbitMqTlsOpts, adminPort :: !Int } - deriving (Show) + deriving (Eq, Show) instance FromJSON RabbitMqAdminOpts where parseJSON = withObject "RabbitMqAdminOpts" $ \v -> @@ -111,7 +115,7 @@ data AmqpEndpoint = AmqpEndpoint vHost :: !Text, tls :: !(Maybe RabbitMqTlsOpts) } - deriving (Show) + deriving (Eq, Show) instance FromJSON AmqpEndpoint where parseJSON = withObject "AmqpEndpoint" $ \v -> @@ -145,6 +149,49 @@ data RabbitMqConnectionError = RabbitMqConnectionFailed String instance Exception RabbitMqConnectionError +-- | Connects with RabbitMQ and opens a channel. +withConnection :: + forall m a. + (MonadIO m, MonadMask m) => + Logger -> + AmqpEndpoint -> + (Q.Connection -> m a) -> + m a +withConnection l AmqpEndpoint {..} k = do + -- Jittered exponential backoff with 1ms as starting delay and 1s as total + -- wait time. + let policy = limitRetriesByCumulativeDelay 1_000_000 $ fullJitterBackoff 1000 + logError willRetry e retryStatus = do + Log.err l $ + Log.msg (Log.val "Failed to connect to RabbitMQ") + . Log.field "error" (displayException @SomeException e) + . Log.field "willRetry" willRetry + . Log.field "retryCount" retryStatus.rsIterNumber + getConn = + recovering + policy + ( skipAsyncExceptions + <> [logRetries (const $ pure True) logError] + ) + ( const $ do + Log.info l $ Log.msg (Log.val "Trying to connect to RabbitMQ") + connOpts <- mkConnectionOpts AmqpEndpoint {..} + liftIO $ Q.openConnection'' connOpts + ) + bracket getConn (liftIO . Q.closeConnection) k + +mkConnectionOpts :: (MonadIO m) => AmqpEndpoint -> m Q.ConnectionOpts +mkConnectionOpts AmqpEndpoint {..} = do + mTlsSettings <- traverse (liftIO . (mkTLSSettings host)) tls + (username, password) <- liftIO $ readCredsFromEnv + pure + Q.defaultConnectionOpts + { Q.coServers = [(host, fromIntegral port)], + Q.coVHost = vHost, + Q.coAuth = [Q.plain username password], + Q.coTLSSettings = fmap Q.TLSCustom mTlsSettings + } + -- | Connects with RabbitMQ and opens a channel. If the channel is closed for -- some reasons, reopens the channel. If the connection is closed for some -- reasons, keeps retrying to connect until it works. @@ -178,15 +225,8 @@ openConnectionWithRetries l AmqpEndpoint {..} hooks = do ) ( const $ do Log.info l $ Log.msg (Log.val "Trying to connect to RabbitMQ") - mTlsSettings <- traverse (liftIO . (mkTLSSettings host)) tls - liftIO $ - Q.openConnection'' $ - Q.defaultConnectionOpts - { Q.coServers = [(host, fromIntegral port)], - Q.coVHost = vHost, - Q.coAuth = [Q.plain username password], - Q.coTLSSettings = fmap Q.TLSCustom mTlsSettings - } + connOpts <- mkConnectionOpts AmqpEndpoint {..} + liftIO $ Q.openConnection'' connOpts ) bracket getConn (liftIO . Q.closeConnection) $ \conn -> do liftBaseWith $ \runInIO -> diff --git a/libs/extended/src/Network/RabbitMqAdmin.hs b/libs/extended/src/Network/RabbitMqAdmin.hs index 68251f97f23..acc6bf8c920 100644 --- a/libs/extended/src/Network/RabbitMqAdmin.hs +++ b/libs/extended/src/Network/RabbitMqAdmin.hs @@ -1,7 +1,7 @@ -- | Perhaps this module should be a separate package and published to hackage. module Network.RabbitMqAdmin where -import Data.Aeson +import Data.Aeson as Aeson import Imports import Servant import Servant.Client @@ -24,6 +24,8 @@ data AdminAPI route = AdminAPI :- "api" :> "queues" :> Capture "vhost" VHost + :> QueryParam "name" Text + :> QueryParam "use_regex" Bool :> Get '[JSON] [Queue], deleteQueue :: route @@ -31,6 +33,19 @@ data AdminAPI route = AdminAPI :> "queues" :> Capture "vhost" VHost :> Capture "queue" QueueName + :> DeleteNoContent, + listConnectionsByVHost :: + route + :- "api" + :> "vhosts" + :> Capture "vhost" Text + :> "connections" + :> Get '[JSON] [Connection], + deleteConnection :: + route + :- "api" + :> "connections" + :> Capture "name" Text :> DeleteNoContent } deriving (Generic) @@ -43,6 +58,9 @@ data AuthenticatedAPI route = AuthenticatedAPI } deriving (Generic) +jsonOptions :: Aeson.Options +jsonOptions = defaultOptions {fieldLabelModifier = camelTo2 '_'} + data Queue = Queue {name :: Text, vhost :: Text} deriving (Show, Eq, Generic) @@ -50,6 +68,18 @@ instance FromJSON Queue instance ToJSON Queue +data Connection = Connection + { userProvidedName :: Maybe Text, + name :: Text + } + deriving (Eq, Show, Generic) + +instance FromJSON Connection where + parseJSON = genericParseJSON jsonOptions + +instance ToJSON Connection where + toJSON = genericToJSON jsonOptions + adminClient :: BasicAuthData -> AdminAPI (AsClientT ClientM) adminClient ba = fromServant $ clientWithAuth.api ba where diff --git a/libs/hscim/CHANGELOG b/libs/hscim/CHANGELOG index 6da7d28ec7e..dd194eba916 100644 --- a/libs/hscim/CHANGELOG +++ b/libs/hscim/CHANGELOG @@ -1,3 +1,17 @@ +0.4.0.6: + - bump dependencies + - re-introduce putUser function accidentally removed in 0.4.0.3 + +0.4.0.4: + - update README + +0.4.0.3: + - drop client function for putUser + - bump ormulu + - bump dependencies + - make delete user idempotent (do not throw 404) + - clean up email address type + 0.4.0: - update dependencies diff --git a/libs/hscim/README.md b/libs/hscim/README.md index 51837f77ee1..296b6133075 100644 --- a/libs/hscim/README.md +++ b/libs/hscim/README.md @@ -3,31 +3,4 @@ This implements part of the [SCIM standard](http://www.simplecloud.info) for identity management. The parts that are currently supported are: - * User schema version 2.0 - -## Building - -This project uses stack. You can install the sample executable with - -```sh -stack install -``` - -## Developing and testing - -This library only implements the schemas and endpoints defined by the -SCIM standard. You will need to implement the actual storage by giving -an instance for the `Persistence` class. - -There's a simple in-memory implementation of this class, which is used -for tests. You can run the tests with the standard stack interface: - -```sh -stack test -``` - -# Contributing - -Before submitting a PR, make sure to install [ormolu](https://github.com/tweag/ormolu) -by doing `stack install ormolu` (we pin the version in our `stack.yaml` file) -and run `make format`. + * User schema version 2.0 (partial) diff --git a/libs/hscim/default.nix b/libs/hscim/default.nix index 85fc2e7cd5e..0e7c11dc089 100644 --- a/libs/hscim/default.nix +++ b/libs/hscim/default.nix @@ -47,7 +47,7 @@ }: mkDerivation { pname = "hscim"; - version = "0.4.0.2"; + version = "0.4.0.6"; src = gitignoreSource ./.; isLibrary = true; isExecutable = true; diff --git a/libs/hscim/hscim.cabal b/libs/hscim/hscim.cabal index 46e6f535ac1..5663e74edc5 100644 --- a/libs/hscim/hscim.cabal +++ b/libs/hscim/hscim.cabal @@ -1,6 +1,6 @@ cabal-version: 1.12 name: hscim -version: 0.4.0.2 +version: 0.4.0.6 synopsis: hscim json schema and server implementation description: The README file will answer all the questions you might have @@ -86,7 +86,7 @@ library aeson >=2.1.2 && <2.2 , aeson-qq >=0.8.4 && <0.9 , attoparsec >=0.14.4 && <0.15 - , base >=4.17.2 && <4.19 + , base >=4.17.2 && <4.21 , bytestring >=0.10.4 && <0.12 , case-insensitive >=1.2.1 && <1.3 , email-validate >=2.3.2 && <2.4 @@ -147,14 +147,14 @@ executable hscim-server -Wunused-packages build-depends: - base - , email-validate + base >=4.18.2 && <4.21 + , email-validate >=2.3.2 && <2.4 , hscim - , network-uri - , stm - , stm-containers - , time - , warp + , network-uri >=2.6.4 && <2.7 + , stm >=2.5.1 && <2.6 + , stm-containers >=1.2.1 && <1.3 + , time >=1.12.2 && <1.13 + , warp >=3 && <3.5 default-language: Haskell2010 @@ -203,25 +203,25 @@ test-suite spec build-tool-depends: hspec-discover:hspec-discover build-depends: - aeson + aeson >=2.1.2 && <2.2 , attoparsec , base , bytestring - , email-validate + , email-validate >=2.3.2 && <2.4 , hedgehog - , hscim + , hscim >=0.4.0 && <0.5 , hspec , hspec-expectations , hspec-wai - , http-types + , http-types >=0.12.4 && <0.13 , hw-hspec-hedgehog , indexed-traversable , microlens , network-uri - , servant + , servant >=0.19 && <0.21 , servant-server , stm-containers - , text + , text >=2.0.2 && <2.1 , wai , wai-extra diff --git a/libs/hscim/src/Web/Scim/Client.hs b/libs/hscim/src/Web/Scim/Client.hs index e736534e56e..c80070fb038 100644 --- a/libs/hscim/src/Web/Scim/Client.hs +++ b/libs/hscim/src/Web/Scim/Client.hs @@ -31,6 +31,7 @@ module Web.Scim.Client getUsers, getUser, postUser, + putUser, patchUser, deleteUser, @@ -133,6 +134,15 @@ postUser :: IO (StoredUser tag) postUser env tok = case users (scimClients env) tok of ((_ :<|> (_ :<|> r)) :<|> (_ :<|> (_ :<|> _))) -> r +putUser :: + (HasScimClient tag) => + ClientEnv -> + Maybe (AuthData tag) -> + UserId tag -> + User tag -> + IO (StoredUser tag) +putUser env tok = case users (scimClients env) tok of ((_ :<|> (_ :<|> _)) :<|> (r :<|> (_ :<|> _))) -> r + patchUser :: (HasScimClient tag) => ClientEnv -> diff --git a/libs/jwt-tools/src/Data/Jwt/Tools.hs b/libs/jwt-tools/src/Data/Jwt/Tools.hs index e9c3ce549de..777485f6426 100644 --- a/libs/jwt-tools/src/Data/Jwt/Tools.hs +++ b/libs/jwt-tools/src/Data/Jwt/Tools.hs @@ -167,15 +167,6 @@ generateDpopToken dpopProof uid cid handle displayName tid domain nonce uri meth methodCStr <- liftIO $ newCString $ UTF8.toString $ methodToBS method backendPubkeyBundleCStr <- toCStr backendPubkeyBundle - -- log all variable inputs (can comment in if need to generate new test data) - -- traceM $ "proof = Proof " <> show (_unProof dpopProof) - -- traceM $ "uid = UserId " <> show (_unUserId uid) - -- traceM $ "nonce = Nonce " <> show (_unNonce nonce) - -- traceM $ "expires = ExpiryEpoch " <> show (_unExpiryEpoch maxExpiration) - -- traceM $ "handle = Handle " <> show (_unHandle handle) - -- traceM $ "displayName = DisplayName " <> show (_unDisplayName displayName) - -- traceM $ "tid = TeamId " <> show (_unTeamId tid) - let before = generateDpopAccessTokenFfi dpopProofCStr diff --git a/libs/schema-profunctor/src/Data/Schema.hs b/libs/schema-profunctor/src/Data/Schema.hs index 9f6104e07a9..87fdee9075e 100644 --- a/libs/schema-profunctor/src/Data/Schema.hs +++ b/libs/schema-profunctor/src/Data/Schema.hs @@ -73,6 +73,7 @@ module Data.Schema dispatch, text, parsedText, + parsedTextWithDoc, null_, nullable, element, @@ -638,6 +639,17 @@ parsedText :: SchemaP NamedSwaggerDoc A.Value A.Value Text a parsedText name parser = text name `withParser` (either fail pure . parser) +-- | A schema for a textual value with possible failure. +parsedTextWithDoc :: + Text -> + Text -> + (Text -> Either String a) -> + SchemaP NamedSwaggerDoc A.Value A.Value Text a +parsedTextWithDoc desc name parser = appendDescr (text name) `withParser` (either fail pure . parser) + where + appendDescr :: ValueSchema NamedSwaggerDoc Text -> ValueSchema NamedSwaggerDoc Text + appendDescr = (doc . S.description) %~ (Just . maybe desc (<> ("\n" <> desc))) + -- | A schema for an arbitrary JSON object. jsonObject :: ValueSchema SwaggerDoc A.Object jsonObject = diff --git a/libs/types-common/src/Data/Mailbox.hs b/libs/types-common/src/Data/Mailbox.hs index c9889d051f4..1772284d932 100644 --- a/libs/types-common/src/Data/Mailbox.hs +++ b/libs/types-common/src/Data/Mailbox.hs @@ -97,8 +97,7 @@ obsNoWsCtl = do || (c == 127) ) -obsCtextParser, obsQtextParser :: Parser Char -obsCtextParser = obsNoWsCtl +obsQtextParser :: Parser Char obsQtextParser = obsNoWsCtl quotedPairParser :: Parser Char diff --git a/libs/types-common/src/Data/Range.hs b/libs/types-common/src/Data/Range.hs index b2dd1b60332..4a3ed6a6f49 100644 --- a/libs/types-common/src/Data/Range.hs +++ b/libs/types-common/src/Data/Range.hs @@ -42,9 +42,13 @@ module Data.Range rnil, rcons, (<|), + runcons, rinc, rappend, rsingleton, + rconcat, + rangeSetToList, + rangeListToSet, -- * 'Arbitrary' generators Ranged (..), @@ -86,6 +90,7 @@ import Data.Text qualified as T import Data.Text.Ascii (AsciiChar, AsciiChars, AsciiText, fromAsciiChars) import Data.Text.Ascii qualified as Ascii import Data.Text.Lazy qualified as TL +import Data.Type.Bool import Data.Type.Ord import GHC.TypeNats import Imports @@ -308,13 +313,25 @@ rcast (Range a) = Range a rnil :: (Monoid a) => Range 0 0 a rnil = Range mempty -rcons, (<|) :: (n <= m) => a -> Range n m [a] -> Range n (m + 1) [a] +rcons, (<|) :: (n <= m) => a -> Range n m [a] -> Range (n + 1) (m + 1) [a] rcons a (Range aa) = Range (a : aa) infixr 5 <| (<|) = rcons +runcons :: + ( n <= m, + n' ~ If (n >=? 1) (n - 1) 0, + m' ~ If (m >=? 1) (m - 1) 0 + ) => + Range n m [a] -> + Maybe (a, Range n' m' [a]) +runcons r = + case fromRange r of + [] -> Nothing + (x : xs) -> Just (x, Range xs) + rinc :: (Integral a, n <= m) => Range n m a -> Range n (m + 1) a rinc (Range a) = Range (a + 1) @@ -330,6 +347,20 @@ rangedNumToParamSchema _ = & S.minimum_ ?~ fromKnownNat (Proxy @n) & S.maximum_ ?~ fromKnownNat (Proxy @m) +rconcat :: Range n m [Range 0 1 [a]] -> Range 0 m [a] +rconcat (Range rs) = Range $ concatMap fromRange rs + +-- | Going from a set to a List should keep the same range because the number of +-- elements cannot grow or shrink. +rangeSetToList :: Range n m (Set a) -> Range n m [a] +rangeSetToList = Range . Set.toList . fromRange + +-- | A list can only shrink when it is converted to a Set, so the min bound +-- changes to 0 if the list can be empty, otherwise the min bound is 1 as the +-- list is guaranteed to have at least 1 element. +rangeListToSet :: (If (n >=? 1) (n' ~ 1) (n' ~ 0), Ord a) => Range n m [a] -> Range n' m (Set a) +rangeListToSet = Range . Set.fromList . fromRange + ----------------------------------------------------------------------------- class Bounds a where diff --git a/libs/wai-utilities/src/Network/Wai/Utilities/Server.hs b/libs/wai-utilities/src/Network/Wai/Utilities/Server.hs index 20f8fc9b934..b51ade2c18b 100644 --- a/libs/wai-utilities/src/Network/Wai/Utilities/Server.hs +++ b/libs/wai-utilities/src/Network/Wai/Utilities/Server.hs @@ -208,6 +208,7 @@ requestIdMiddleware logger reqIdHeaderName origApp req responder = let reqWithId = req {requestHeaders = (reqIdHeaderName, reqId) : req.requestHeaders} origApp reqWithId responder +{-# INLINEABLE catchErrors #-} catchErrors :: Logger -> HeaderName -> Middleware catchErrors l reqIdHeaderName = catchErrorsWithRequestId (lookupRequestId reqIdHeaderName) l @@ -232,8 +233,6 @@ catchErrorsWithRequestId getRequestId l app req k = er <- runHandlers ex errorHandlers onError l mReqId req k er -{-# INLINEABLE catchErrors #-} - -- | Standard handlers for turning exceptions into appropriate -- 'Error' responses. errorHandlers :: [Handler IO (Either Wai.Error JSONResponse)] @@ -349,7 +348,7 @@ rethrow5xx getRequestId logger app req k = app req k' k' resp@WaiInt.ResponseRaw {} = do -- See Note [Raw Response] let logMsg = - field "canoncalpath" (show $ pathInfo req) + field "canonicalpath" (show $ pathInfo req) . field "rawpath" (rawPathInfo req) . field "request" (fromMaybe defRequestId $ getRequestId req) . msg (val "ResponseRaw - cannot collect metrics or log info on errors") diff --git a/libs/wire-api/default.nix b/libs/wire-api/default.nix index 6249b98c695..9b6c2c3bd56 100644 --- a/libs/wire-api/default.nix +++ b/libs/wire-api/default.nix @@ -247,6 +247,7 @@ mkDerivation { proto-lens QuickCheck random + saml2-web-sso schema-profunctor servant servant-server diff --git a/libs/wire-api/src/Wire/API/Asset.hs b/libs/wire-api/src/Wire/API/Asset.hs index d2a53bad442..a5496f42746 100644 --- a/libs/wire-api/src/Wire/API/Asset.hs +++ b/libs/wire-api/src/Wire/API/Asset.hs @@ -177,8 +177,11 @@ assetKeyToText = T.decodeUtf8 . toByteString' instance ToSchema AssetKey where schema = assetKeyToText - .= parsedText "AssetKey" (runParser parser . T.encodeUtf8) + .= parsedTextWithDoc desc "AssetKey" (runParser parser . T.encodeUtf8) & doc' . S.schema . S.example ?~ toJSON ("3-1-47de4580-ae51-4650-acbb-d10c028cb0ac" :: Text) + where + desc = + "S3 asset key for an icon image with retention information." instance S.ToParamSchema AssetKey where toParamSchema _ = S.toParamSchema (Proxy @Text) diff --git a/libs/wire-api/src/Wire/API/Error/Brig.hs b/libs/wire-api/src/Wire/API/Error/Brig.hs index 9c397736cc2..f5d2c28ad72 100644 --- a/libs/wire-api/src/Wire/API/Error/Brig.hs +++ b/libs/wire-api/src/Wire/API/Error/Brig.hs @@ -67,6 +67,7 @@ data BrigError | NameManagedByScim | HandleManagedByScim | LocaleManagedByScim + | EmailManagedByScim | LastIdentity | NoPassword | ChangePasswordMustDiffer @@ -101,6 +102,7 @@ data BrigError | PropertyValueTooLarge | UserAlreadyInATeam | MLSServicesNotAllowed + | NotificationQueueConnectionError instance (Typeable (MapError e), KnownError (MapError e)) => IsSwaggerError (e :: BrigError) where addToOpenApi = addStaticErrorToSwagger @(MapError e) @@ -247,6 +249,8 @@ type instance MapError 'HandleManagedByScim = 'StaticError 403 "managed-by-scim" type instance MapError 'LocaleManagedByScim = 'StaticError 403 "managed-by-scim" "Updating locale is not allowed, because it is managed by SCIM, or E2EId is enabled" +type instance MapError 'EmailManagedByScim = 'StaticError 403 "managed-by-scim" "Updating email is not allowed, because it is managed by SCIM, or E2EId is enabled" + type instance MapError 'LastIdentity = 'StaticError 403 "last-identity" "The last user identity cannot be removed." type instance MapError 'NoPassword = 'StaticError 403 "no-password" "The user has no password." @@ -301,3 +305,5 @@ type instance MapError 'PropertyValueTooLarge = 'StaticError 403 "property-value type instance MapError 'UserAlreadyInATeam = 'StaticError 403 "user-already-in-a-team" "Switching teams is not allowed" type instance MapError 'MLSServicesNotAllowed = 'StaticError 409 "mls-services-not-allowed" "Services not allowed in MLS" + +type instance MapError 'NotificationQueueConnectionError = 'StaticError 500 "internal-server-error" "Internal server error" diff --git a/libs/wire-api/src/Wire/API/Event/WebSocketProtocol.hs b/libs/wire-api/src/Wire/API/Event/WebSocketProtocol.hs new file mode 100644 index 00000000000..4a9f9d5b7fe --- /dev/null +++ b/libs/wire-api/src/Wire/API/Event/WebSocketProtocol.hs @@ -0,0 +1,134 @@ +{-# LANGUAGE TemplateHaskell #-} + +module Wire.API.Event.WebSocketProtocol where + +import Control.Lens (makePrisms) +import Data.Aeson (FromJSON, ToJSON) +import Data.Aeson qualified as A +import Data.Aeson.Types qualified as A +import Data.Schema +import Data.Word +import Imports +import Wire.API.Internal.Notification +import Wire.Arbitrary + +data AckData = AckData + { deliveryTag :: Word64, + -- | Acknowledge all deliveryTags <= 'deliveryTag', see RabbitMQ + -- documenation: + -- https://www.rabbitmq.com/docs/confirms#consumer-acks-multiple-parameter + multiple :: Bool + } + deriving (Show, Eq, Generic) + deriving (Arbitrary) via (GenericUniform AckData) + deriving (FromJSON, ToJSON) via (Schema AckData) + +instance ToSchema AckData where + schema = + object "AckData" $ + AckData + <$> (.deliveryTag) .= field "delivery_tag" schema + <*> multiple .= field "multiple" schema + +data EventData = EventData + { event :: QueuedNotification, + deliveryTag :: Word64 + } + deriving (Show, Eq, Generic) + deriving (Arbitrary) via (GenericUniform EventData) + deriving (FromJSON, ToJSON) via (Schema EventData) + +instance ToSchema EventData where + schema = + object "EventData" $ + EventData + <$> event .= field "event" schema + <*> (.deliveryTag) .= field "delivery_tag" schema + +data MessageServerToClient + = EventMessage EventData + | EventFullSync + deriving (Show, Eq, Generic) + deriving (Arbitrary) via (GenericUniform MessageServerToClient) + +makePrisms ''MessageServerToClient + +data MessageClientToServer + = AckMessage AckData + | AckFullSync + deriving (Show, Eq, Generic) + deriving (Arbitrary) via (GenericUniform MessageClientToServer) + +makePrisms ''MessageClientToServer + +---------------------------------------------------------------------- +-- ServerToClient + +-- | Local type, only needed for writing the ToSchema instance for 'MessageServerToClient'. +data MessageTypeServerToClient = MsgTypeEventMessage | MsgTypeEventFullSync + deriving (Eq, Enum, Bounded) + +msgTypeSchemaServerToClient :: ValueSchema NamedSwaggerDoc MessageTypeServerToClient +msgTypeSchemaServerToClient = + enum @Text "MessageTypeServerToClient" $ + mconcat $ + [ element "event" MsgTypeEventMessage, + element "notifications.missed" MsgTypeEventFullSync + ] + +instance ToSchema MessageServerToClient where + schema = + object "MessageServerToClient" $ + fromTagged <$> toTagged .= bind (fst .= field "type" msgTypeSchemaServerToClient) (snd .= untaggedSchema) + where + toTagged :: MessageServerToClient -> (MessageTypeServerToClient, MessageServerToClient) + toTagged d@(EventMessage _) = (MsgTypeEventMessage, d) + toTagged d@EventFullSync = (MsgTypeEventFullSync, d) + + fromTagged :: (MessageTypeServerToClient, MessageServerToClient) -> MessageServerToClient + fromTagged = snd + + untaggedSchema :: SchemaP SwaggerDoc (A.Object, MessageTypeServerToClient) [A.Pair] (MessageServerToClient) (MessageServerToClient) + untaggedSchema = dispatch $ \case + MsgTypeEventMessage -> tag _EventMessage (field "data" schema) + MsgTypeEventFullSync -> tag _EventFullSync (pure ()) + +deriving via Schema MessageServerToClient instance FromJSON MessageServerToClient + +deriving via Schema MessageServerToClient instance ToJSON MessageServerToClient + +---------------------------------------------------------------------- +-- ClientToServer + +-- | Local type, only needed for writing the ToSchema instance for 'MessageClientToServer'. +data MessageTypeClientToServer = MsgTypeAckMessage | MsgTypeAckFullSync + deriving (Eq, Enum, Bounded) + +msgTypeSchemaClientToServer :: ValueSchema NamedSwaggerDoc MessageTypeClientToServer +msgTypeSchemaClientToServer = + enum @Text "MessageTypeClientToServer" $ + mconcat $ + [ element "ack" MsgTypeAckMessage, + element "ack_full_sync" MsgTypeAckFullSync + ] + +instance ToSchema MessageClientToServer where + schema = + object "MessageClientToServer" $ + fromTagged <$> toTagged .= bind (fst .= field "type" msgTypeSchemaClientToServer) (snd .= untaggedSchema) + where + toTagged :: MessageClientToServer -> (MessageTypeClientToServer, MessageClientToServer) + toTagged d@(AckMessage _) = (MsgTypeAckMessage, d) + toTagged d@AckFullSync = (MsgTypeAckFullSync, d) + + fromTagged :: (MessageTypeClientToServer, MessageClientToServer) -> MessageClientToServer + fromTagged = snd + + untaggedSchema :: SchemaP SwaggerDoc (A.Object, MessageTypeClientToServer) [A.Pair] MessageClientToServer MessageClientToServer + untaggedSchema = dispatch $ \case + MsgTypeAckMessage -> tag _AckMessage (field "data" schema) + MsgTypeAckFullSync -> tag _AckFullSync (pure ()) + +deriving via Schema MessageClientToServer instance FromJSON MessageClientToServer + +deriving via Schema MessageClientToServer instance ToJSON MessageClientToServer diff --git a/libs/wire-api/src/Wire/API/Notification.hs b/libs/wire-api/src/Wire/API/Notification.hs index 83317eb5259..cafb68bfacf 100644 --- a/libs/wire-api/src/Wire/API/Notification.hs +++ b/libs/wire-api/src/Wire/API/Notification.hs @@ -35,6 +35,12 @@ module Wire.API.Notification queuedHasMore, queuedTime, GetNotificationsResponse (..), + userNotificationExchangeName, + userNotificationDlxName, + userNotificationDlqName, + clientNotificationQueueName, + userRoutingKey, + clientRoutingKey, ) where @@ -166,3 +172,28 @@ instance AsUnion '[Respond 404 "Notification list" QueuedNotificationList, Respo fromUnion (S (Z (I xs))) = GetNotificationsSuccess xs fromUnion (Z (I xs)) = GetNotificationsWithStatusNotFound xs fromUnion (S (S x)) = case x of {} + +-------------------------------------------------------------------------------- +-- RabbitMQ exchanges and queues + +-- | The name of the RabbitMQ exchange to which user notifications are published. +userNotificationExchangeName :: Text +userNotificationExchangeName = "user-notifications" + +-- | The name of the RabbitMQ dead letter exchange for user notifications. +userNotificationDlxName :: Text +userNotificationDlxName = "dead-user-notifications" + +-- | The name of the RabbitMQ queue for dead-lettered user notifications. +userNotificationDlqName :: Text +userNotificationDlqName = "dead-user-notifications" + +clientNotificationQueueName :: UserId -> ClientId -> Text +clientNotificationQueueName uid cid = + "user-notifications." <> clientRoutingKey uid cid + +userRoutingKey :: UserId -> Text +userRoutingKey = idToText + +clientRoutingKey :: UserId -> ClientId -> Text +clientRoutingKey uid cid = userRoutingKey uid <> "." <> clientToText cid diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Galley/TeamsIntra.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Galley/TeamsIntra.hs index 0bc3ae5a593..749e5fe124c 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Galley/TeamsIntra.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Galley/TeamsIntra.hs @@ -27,7 +27,6 @@ module Wire.API.Routes.Internal.Galley.TeamsIntra ) where -import Control.Lens ((?~)) import Data.Aeson import Data.Currency qualified as Currency import Data.Json.Util @@ -53,7 +52,7 @@ data TeamStatus instance S.ToSchema TeamStatus where schema = - S.enum @Text "Access" $ + S.enum @Text "TeamStatus" $ mconcat [ S.element "active" Active, S.element "pending_delete" PendingDelete, @@ -82,7 +81,6 @@ instance S.ToSchema TeamData where data TeamStatusUpdate = TeamStatusUpdate { tuStatus :: !TeamStatus, tuCurrency :: !(Maybe Currency.Alpha) - -- TODO: Remove Currency selection once billing supports currency changes after team creation } deriving (Eq, Show, Generic) deriving (Arbitrary) via GenericUniform TeamStatusUpdate @@ -93,15 +91,7 @@ instance S.ToSchema TeamStatusUpdate where S.object "TeamStatusUpdate" $ TeamStatusUpdate <$> tuStatus S..= S.field "status" S.schema - <*> tuCurrency S..= S.maybe_ (S.optField "currency" currencyAlphaSchema) - where - currencyAlphaSchema :: S.ValueSchema S.NamedSwaggerDoc Currency.Alpha - currencyAlphaSchema = S.mkSchema docs parseJSON (pure . toJSON) - where - docs = - S.swaggerDoc @Text - & Swagger.schema . Swagger.description ?~ "ISO 4217 alphabetic codes" - & Swagger.schema . Swagger.example ?~ "EUR" + <*> tuCurrency S..= S.maybe_ (S.optField "currency" S.genericToSchema) newtype TeamName = TeamName {tnName :: Text} diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Gundeck.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Gundeck.hs index c786d1c3020..b9f5eb40daf 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Gundeck.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Gundeck.hs @@ -95,6 +95,15 @@ type InternalAPI = :<|> Named "i-clients-delete" (ZUser :> "clients" :> Capture "cid" ClientId :> Delete '[JSON] NoContent) :<|> Named "i-user-delete" (ZUser :> "user" :> Delete '[JSON] NoContent) :<|> Named "i-push-tokens-get" ("push-tokens" :> Capture "uid" UserId :> Get '[JSON] PushTokenList) + :<|> Named + "i-reg-consumable-notifs" + ( "users" + :> Capture "uid" UserId + :> "clients" + :> Capture "cid" ClientId + :> "consumable-notifications" + :> PostNoContent + ) ) swaggerDoc :: S.OpenApi 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 d377fc79835..a554f2aeba9 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -363,7 +363,7 @@ type SelfAPI = :> Description "Your email address can only be removed if you also have a \ \phone number." - :> ZUser + :> ZLocalUser :> "self" :> "email" :> MultiVerb 'DELETE '[JSON] RemoveIdentityResponses (Maybe RemoveIdentityError) @@ -750,7 +750,7 @@ type UserClientAPI = -- - ClientAdded event to self -- - ClientRemoved event to self, if removing old clients due to max number Named - "add-client-v6" + "add-client@v6" ( Summary "Register a new client" :> Until 'V7 :> CanThrow 'TooManyClients @@ -761,7 +761,7 @@ type UserClientAPI = :> ZLocalUser :> ZConn :> "clients" - :> ReqBody '[JSON] NewClient + :> VersionedReqBody 'V6 '[JSON] NewClient :> MultiVerb1 'POST '[JSON] @@ -771,10 +771,33 @@ type UserClientAPI = (VersionedRespond 'V6 201 "Client registered" Client) ) ) + :<|> Named + "add-client@v7" + ( Summary "Register a new client" + :> From 'V7 + :> Until 'V8 + :> CanThrow 'TooManyClients + :> CanThrow 'MissingAuth + :> CanThrow 'MalformedPrekeys + :> CanThrow 'CodeAuthenticationFailed + :> CanThrow 'CodeAuthenticationRequired + :> ZLocalUser + :> ZConn + :> "clients" + :> VersionedReqBody 'V7 '[JSON] NewClient + :> MultiVerb1 + 'POST + '[JSON] + ( WithHeaders + ClientHeaders + Client + (VersionedRespond 'V7 201 "Client registered" Client) + ) + ) :<|> Named "add-client" ( Summary "Register a new client" - :> From 'V6 + :> From 'V8 :> CanThrow 'TooManyClients :> CanThrow 'MissingAuth :> CanThrow 'MalformedPrekeys @@ -793,15 +816,39 @@ type UserClientAPI = (Respond 201 "Client registered" Client) ) ) + :<|> Named + "update-client@v6" + ( Summary "Update a registered client" + :> Until 'V7 + :> CanThrow 'MalformedPrekeys + :> ZUser + :> "clients" + :> CaptureClientId "client" + :> VersionedReqBody 'V6 '[JSON] UpdateClient + :> MultiVerb1 'PUT '[JSON] (RespondEmpty 200 "Client updated") + ) + :<|> Named + "update-client@v7" + ( Summary "Update a registered client" + :> From 'V7 + :> Until 'V8 + :> CanThrow 'MalformedPrekeys + :> ZUser + :> "clients" + :> CaptureClientId "client" + :> VersionedReqBody 'V7 '[JSON] UpdateClient + :> MultiVerb1 'PUT '[JSON] (RespondEmpty 200 "Client updated") + ) :<|> Named "update-client" ( Summary "Update a registered client" + :> From 'V8 :> CanThrow 'MalformedPrekeys :> ZUser :> "clients" :> CaptureClientId "client" :> ReqBody '[JSON] UpdateClient - :> MultiVerb 'PUT '[JSON] '[RespondEmpty 200 "Client updated"] () + :> MultiVerb1 'PUT '[JSON] (RespondEmpty 200 "Client updated") ) :<|> -- This endpoint can lead to the following events being sent: @@ -817,7 +864,7 @@ type UserClientAPI = :> MultiVerb 'DELETE '[JSON] '[RespondEmpty 200 "Client deleted"] () ) :<|> Named - "list-clients-v6" + "list-clients@v6" ( Summary "List the registered clients" :> Until 'V7 :> ZUser @@ -829,9 +876,22 @@ type UserClientAPI = ) ) :<|> Named - "list-clients" + "list-clients@v7" ( Summary "List the registered clients" :> From 'V7 + :> Until 'V8 + :> ZUser + :> "clients" + :> MultiVerb1 + 'GET + '[JSON] + ( VersionedRespond 'V7 200 "List of clients" [Client] + ) + ) + :<|> Named + "list-clients" + ( Summary "List the registered clients" + :> From 'V8 :> ZUser :> "clients" :> MultiVerb1 @@ -841,7 +901,7 @@ type UserClientAPI = ) ) :<|> Named - "get-client-v6" + "get-client@v6" ( Summary "Get a registered client by ID" :> Until 'V7 :> ZUser @@ -856,9 +916,25 @@ type UserClientAPI = (Maybe Client) ) :<|> Named - "get-client" + "get-client@v7" ( Summary "Get a registered client by ID" :> From 'V7 + :> Until 'V8 + :> ZUser + :> "clients" + :> CaptureClientId "client" + :> MultiVerb + 'GET + '[JSON] + '[ EmptyErrorForLegacyReasons 404 "Client not found", + VersionedRespond 'V7 200 "Client found" Client + ] + (Maybe Client) + ) + :<|> Named + "get-client" + ( Summary "Get a registered client by ID" + :> From 'V8 :> ZUser :> "clients" :> CaptureClientId "client" @@ -870,9 +946,37 @@ type UserClientAPI = ] (Maybe Client) ) + :<|> Named + "get-client-capabilities@v6" + ( Summary "Read back what the client has been posting about itself" + :> Until 'V7 + :> ZUser + :> "clients" + :> CaptureClientId "client" + :> "capabilities" + :> MultiVerb1 + 'GET + '[JSON] + (VersionedRespond 'V6 200 "capabilities" ClientCapabilityList) + ) + :<|> Named + "get-client-capabilities@v7" + ( Summary "Read back what the client has been posting about itself" + :> From 'V7 + :> Until 'V8 + :> ZUser + :> "clients" + :> CaptureClientId "client" + :> "capabilities" + :> MultiVerb1 + 'GET + '[JSON] + (VersionedRespond 'V7 200 "capabilities" ClientCapabilityList) + ) :<|> Named "get-client-capabilities" ( Summary "Read back what the client has been posting about itself" + :> From 'V8 :> ZUser :> "clients" :> CaptureClientId "client" @@ -1546,8 +1650,9 @@ type CallingAPI = type TeamsAPI = Named - "send-team-invitation" + "send-team-invitation@v6" ( Summary "Create and send a new team invitation." + :> Until V7 :> Description "Invitations are sent by email. The maximum allowed number of \ \pending team invitations is equal to the team size." @@ -1562,7 +1667,7 @@ type TeamsAPI = :> "teams" :> Capture "tid" TeamId :> "invitations" - :> ReqBody '[JSON] InvitationRequest + :> VersionedReqBody V6 '[JSON] InvitationRequest :> MultiVerb1 'POST '[JSON] @@ -1572,6 +1677,34 @@ type TeamsAPI = (Respond 201 "Invitation was created and sent." Invitation) ) ) + :<|> Named + "send-team-invitation" + ( Summary "Create and send a new team invitation." + :> From V7 + :> Description + "Invitations are sent by email. The maximum allowed number of \ + \pending team invitations is equal to the team size." + :> CanThrow 'NoEmail + :> CanThrow 'NoIdentity + :> CanThrow 'InvalidEmail + :> CanThrow 'BlacklistedEmail + :> CanThrow 'TooManyTeamInvitations + :> CanThrow 'InsufficientTeamPermissions + :> CanThrow 'InvalidInvitationCode + :> ZLocalUser + :> "teams" + :> Capture "tid" TeamId + :> "invitations" + :> ReqBody '[JSON] InvitationRequest + :> MultiVerb1 + 'POST + '[JSON] + ( WithHeaders + '[Header "Location" InvitationLocation] + (Invitation, InvitationLocation) + (Respond 201 "Invitation was created and sent." Invitation) + ) + ) :<|> Named "get-team-invitations" ( Summary "List the sent team invitations" @@ -1626,7 +1759,7 @@ type TeamsAPI = :> MultiVerb1 'GET '[JSON] - (Respond 200 "Invitation info" Invitation) + (Respond 200 "Invitation info" InvitationUserView) ) -- FUTUREWORK: Add another endpoint to allow resending of invitation codes :<|> Named diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig/Bot.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig/Bot.hs index 4c072cf0210..f7a7868b561 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig/Bot.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig/Bot.hs @@ -146,7 +146,7 @@ type BotAPI = :> MultiVerb1 'POST '[JSON] (RespondEmpty 200 "") ) :<|> Named - "bot-get-client-v6" + "bot-get-client@v6" ( Summary "Get client for bot" :> Until 'V7 :> CanThrow 'AccessDenied @@ -163,9 +163,27 @@ type BotAPI = (Maybe Client) ) :<|> Named - "bot-get-client" + "bot-get-client@v7" ( Summary "Get client for bot" :> From 'V7 + :> Until 'V8 + :> CanThrow 'AccessDenied + :> CanThrow 'ClientNotFound + :> ZBot + :> "bot" + :> "client" + :> MultiVerb + 'GET + '[JSON] + '[ ErrorResponse 'ClientNotFound, + VersionedRespond 'V7 200 "Client found" Client + ] + (Maybe Client) + ) + :<|> Named + "bot-get-client" + ( Summary "Get client for bot" + :> From 'V8 :> CanThrow 'AccessDenied :> CanThrow 'ClientNotFound :> ZBot diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Cannon.hs b/libs/wire-api/src/Wire/API/Routes/Public/Cannon.hs index eda1f01a8e3..b339371be08 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Cannon.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Cannon.hs @@ -22,12 +22,14 @@ import Servant import Wire.API.Routes.API import Wire.API.Routes.Named import Wire.API.Routes.Public (ZConn, ZUser) +import Wire.API.Routes.Version import Wire.API.Routes.WebSocket type CannonAPI = Named "await-notifications" ( Summary "Establish websocket connection" + -- Description "This is the legacy variant of \"consume-events\"" :> "await" :> ZUser :> ZConn @@ -41,6 +43,24 @@ type CannonAPI = -- FUTUREWORK: Consider higher-level web socket combinator :> WebSocketPending ) + :<|> Named + "consume-events" + ( Summary "Consume events over a websocket connection" + :> Description "This is the rabbitMQ-based variant of \"await-notifications\"" + :> From 'V8 + :> "events" + :> ZUser + :> QueryParam' + [ -- Make this optional in https://wearezeta.atlassian.net/browse/WPB-11173 + Required, + Strict, + Description "Client ID" + ] + "client" + ClientId + -- FUTUREWORK: Consider higher-level web socket combinator + :> WebSocketPending + ) data CannonAPITag diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Spar.hs b/libs/wire-api/src/Wire/API/Routes/Public/Spar.hs index 787da9d22a2..5e9de5a5111 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Spar.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Spar.hs @@ -114,6 +114,7 @@ type APIIDP = Named "idp-get" (ZOptUser :> IdpGet) :<|> Named "idp-get-raw" (ZOptUser :> IdpGetRaw) :<|> Named "idp-get-all" (ZOptUser :> IdpGetAll) + :<|> Named "idp-create@v7" (Until 'V8 :> ZOptUser :> IdpCreate) -- (change is semantic, see handler) :<|> Named "idp-create" (ZOptUser :> IdpCreate) :<|> Named "idp-update" (ZOptUser :> IdpUpdate) :<|> Named "idp-delete" (ZOptUser :> IdpDelete) @@ -189,21 +190,21 @@ data ScimSite tag route = ScimSite deriving (Generic) type APIScimToken = - Named "auth-tokens-create@v6" (Until 'V7 :> ZOptUser :> APIScimTokenCreateV6) - :<|> Named "auth-tokens-create" (From 'V7 :> ZOptUser :> APIScimTokenCreate) - :<|> Named "auth-tokens-put-name" (From 'V7 :> ZUser :> APIScimTokenPutName) + Named "auth-tokens-create@v7" (Until 'V8 :> ZOptUser :> APIScimTokenCreateV7) + :<|> Named "auth-tokens-create" (From 'V8 :> ZOptUser :> APIScimTokenCreate) + :<|> Named "auth-tokens-put-name" (From 'V8 :> ZUser :> APIScimTokenPutName) :<|> Named "auth-tokens-delete" (ZOptUser :> APIScimTokenDelete) - :<|> Named "auth-tokens-list@v6" (Until 'V7 :> ZOptUser :> APIScimTokenListV6) - :<|> Named "auth-tokens-list" (From 'V7 :> ZOptUser :> APIScimTokenList) + :<|> Named "auth-tokens-list@v7" (Until 'V8 :> ZOptUser :> APIScimTokenListV7) + :<|> Named "auth-tokens-list" (From 'V8 :> ZOptUser :> APIScimTokenList) type APIScimTokenPutName = Capture "id" ScimTokenId :> ReqBody '[JSON] ScimTokenName :> Put '[JSON] () -type APIScimTokenCreateV6 = - VersionedReqBody 'V6 '[JSON] CreateScimToken - :> Post '[JSON] CreateScimTokenResponseV6 +type APIScimTokenCreateV7 = + VersionedReqBody 'V7 '[JSON] CreateScimToken + :> Post '[JSON] CreateScimTokenResponseV7 type APIScimTokenCreate = ReqBody '[JSON] CreateScimToken @@ -216,8 +217,8 @@ type APIScimTokenDelete = type APIScimTokenList = Get '[JSON] ScimTokenList -type APIScimTokenListV6 = - Get '[JSON] ScimTokenListV6 +type APIScimTokenListV7 = + Get '[JSON] ScimTokenListV7 data SparAPITag diff --git a/libs/wire-api/src/Wire/API/Routes/Version.hs b/libs/wire-api/src/Wire/API/Routes/Version.hs index 92e095f360f..37659155caa 100644 --- a/libs/wire-api/src/Wire/API/Routes/Version.hs +++ b/libs/wire-api/src/Wire/API/Routes/Version.hs @@ -32,6 +32,7 @@ module Wire.API.Routes.Version Version (..), versionInt, versionText, + versionedName, VersionNumber (..), VersionExp (..), supportedVersions, @@ -80,7 +81,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 +data Version = V0 | V1 | V2 | V3 | V4 | V5 | V6 | V7 | V8 deriving stock (Eq, Ord, Bounded, Enum, Show, Generic) deriving (FromJSON, ToJSON) via (Schema Version) deriving (Arbitrary) via (GenericUniform Version) @@ -100,6 +101,7 @@ versionInt V4 = 4 versionInt V5 = 5 versionInt V6 = 6 versionInt V7 = 7 +versionInt V8 = 8 supportedVersions :: [Version] supportedVersions = [minBound .. maxBound] @@ -107,6 +109,10 @@ supportedVersions = [minBound .. maxBound] maxAvailableVersion :: Set Version -> Maybe Version maxAvailableVersion disabled = Set.lookupMax $ Set.fromList supportedVersions \\ disabled +versionedName :: Maybe Version -> Text -> Text +versionedName Nothing unversionedName = unversionedName +versionedName (Just v) unversionedName = unversionedName <> Text.pack (show v) + ---------------------------------------------------------------------- versionText :: Version -> Text @@ -210,7 +216,8 @@ isDevelopmentVersion V3 = False isDevelopmentVersion V4 = False isDevelopmentVersion V5 = False isDevelopmentVersion V6 = False -isDevelopmentVersion _ = True +isDevelopmentVersion V7 = False +isDevelopmentVersion V8 = True developmentVersions :: [Version] developmentVersions = filter isDevelopmentVersion supportedVersions diff --git a/libs/wire-api/src/Wire/API/Routes/Versioned.hs b/libs/wire-api/src/Wire/API/Routes/Versioned.hs index 640d91fd022..db2b7e9dae3 100644 --- a/libs/wire-api/src/Wire/API/Routes/Versioned.hs +++ b/libs/wire-api/src/Wire/API/Routes/Versioned.hs @@ -23,6 +23,7 @@ import Data.Metrics.Servant import Data.OpenApi qualified as S import Data.Schema import Data.Singletons +import Data.Text qualified as Text import GHC.TypeLits import Imports import Servant @@ -116,4 +117,13 @@ deriving via Schema (Versioned v a) instance (ToSchema (Versioned v a)) => ToJSO instance (SingI v, ToSchema (Versioned v a), Typeable a, Typeable v) => S.ToSchema (Versioned v a) where declareNamedSchema _ = do S.NamedSchema n s <- schemaToSwagger (Proxy @(Versioned v a)) - pure $ S.NamedSchema (fmap (<> toUrlPiece (demote @v)) n) s + pure $ S.NamedSchema (fmap withVersionSuffix n) s + where + versionSuffix :: Text + versionSuffix = Text.pack $ show (demote @v) + + withVersionSuffix :: Text -> Text + withVersionSuffix origName = + if versionSuffix `Text.isSuffixOf` origName + then origName + else origName <> versionSuffix diff --git a/libs/wire-api/src/Wire/API/Team.hs b/libs/wire-api/src/Wire/API/Team.hs index a1fc3c99b8a..283dbaff55b 100644 --- a/libs/wire-api/src/Wire/API/Team.hs +++ b/libs/wire-api/src/Wire/API/Team.hs @@ -62,7 +62,7 @@ module Wire.API.Team where import Control.Lens (makeLenses, over, (?~)) -import Data.Aeson (FromJSON, ToJSON, Value (..)) +import Data.Aeson (FromJSON, ToJSON, Value (..), toJSON) import Data.Aeson.Types (Parser) import Data.Attoparsec.ByteString qualified as Atto (Parser, string) import Data.Attoparsec.Combinator (choice) @@ -183,8 +183,8 @@ newTeamObjectSchema :: ObjectSchema SwaggerDoc NewTeam newTeamObjectSchema = NewTeam <$> newTeamName .= fieldWithDocModifier "name" (description ?~ "team name") schema - <*> newTeamIcon .= fieldWithDocModifier "icon" (description ?~ "team icon (asset ID)") schema - <*> newTeamIconKey .= maybe_ (optFieldWithDocModifier "icon_key" (description ?~ "team icon asset key") schema) + <*> newTeamIcon .= field "icon" schema + <*> newTeamIconKey .= maybe_ (optFieldWithDocModifier "icon_key" (description ?~ "The decryption key for the team icon S3 asset") schema) instance ToSchema NewTeam where schema = object "NewTeam" newTeamObjectSchema @@ -214,7 +214,11 @@ instance ToByteString Icon where instance ToSchema Icon where schema = (T.decodeUtf8 . toByteString') - .= parsedText "Icon" (runParser parser . T.encodeUtf8) + .= parsedTextWithDoc desc "Icon" (runParser parser . T.encodeUtf8) + & doc' . S.schema . S.example ?~ toJSON ("3-1-47de4580-ae51-4650-acbb-d10c028cb0ac" :: Text) + where + desc = + "S3 asset key for an icon image with retention information. Allows special value 'default'." data TeamUpdateData = TeamUpdateData { _nameUpdate :: Maybe (Range 1 256 Text), diff --git a/libs/wire-api/src/Wire/API/Team/Invitation.hs b/libs/wire-api/src/Wire/API/Team/Invitation.hs index 49fe051705a..96d268c8258 100644 --- a/libs/wire-api/src/Wire/API/Team/Invitation.hs +++ b/libs/wire-api/src/Wire/API/Team/Invitation.hs @@ -21,6 +21,7 @@ module Wire.API.Team.Invitation ( InvitationRequest (..), Invitation (..), + InvitationUserView (..), InvitationList (..), InvitationLocation (..), AcceptTeamInvitation (..), @@ -45,6 +46,8 @@ import URI.ByteString import Wire.API.Error import Wire.API.Error.Brig import Wire.API.Routes.MultiVerb +import Wire.API.Routes.Version +import Wire.API.Routes.Versioned import Wire.API.Team.Role (Role, defaultRole) import Wire.API.User import Wire.Arbitrary (Arbitrary, GenericUniform (..)) @@ -56,24 +59,35 @@ data InvitationRequest = InvitationRequest { locale :: Maybe Locale, role :: Maybe Role, inviteeName :: Maybe Name, - inviteeEmail :: EmailAddress + inviteeEmail :: EmailAddress, + allowExisting :: Bool } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform InvitationRequest) deriving (A.FromJSON, A.ToJSON, S.ToSchema) via (Schema InvitationRequest) +instance ToSchema (Versioned V6 InvitationRequest) where + schema = Versioned <$> unVersioned .= invitationRequestSchema False + instance ToSchema InvitationRequest where - schema = - objectWithDocModifier "InvitationRequest" (description ?~ "A request to join a team on Wire.") $ - InvitationRequest - <$> locale - .= optFieldWithDocModifier "locale" (description ?~ "Locale to use for the invitation.") (maybeWithDefault A.Null schema) - <*> (.role) - .= optFieldWithDocModifier "role" (description ?~ "Role of the invitee (invited user).") (maybeWithDefault A.Null schema) - <*> (.inviteeName) - .= optFieldWithDocModifier "name" (description ?~ "Name of the invitee (1 - 128 characters).") (maybeWithDefault A.Null schema) - <*> (.inviteeEmail) - .= fieldWithDocModifier "email" (description ?~ "Email of the invitee.") schema + schema = invitationRequestSchema True + +invitationRequestSchema :: Bool -> ValueSchema NamedSwaggerDoc InvitationRequest +invitationRequestSchema allowExisting = + objectWithDocModifier "InvitationRequest" (description ?~ "A request to join a team on Wire.") $ + InvitationRequest + <$> locale + .= optFieldWithDocModifier "locale" (description ?~ "Locale to use for the invitation.") (maybeWithDefault A.Null schema) + <*> (.role) + .= optFieldWithDocModifier "role" (description ?~ "Role of the invitee (invited user).") (maybeWithDefault A.Null schema) + <*> (.inviteeName) + .= optFieldWithDocModifier "name" (description ?~ "Name of the invitee (1 - 128 characters).") (maybeWithDefault A.Null schema) + <*> (.inviteeEmail) + .= fieldWithDocModifier "email" (description ?~ "Email of the invitee.") schema + <*> (.allowExisting) + .= ( fromMaybe allowExisting + <$> optFieldWithDocModifier "allow_existing" (description ?~ "Whether invitations to existing users are allowed.") schema + ) -------------------------------------------------------------------------------- -- Invitation @@ -98,27 +112,31 @@ instance ToSchema Invitation where schema = objectWithDocModifier "Invitation" - (description ?~ "An invitation to join a team on Wire") - $ Invitation - <$> (.team) - .= fieldWithDocModifier "team" (description ?~ "Team ID of the inviting team") schema - <*> (.role) - -- clients, when leaving "role" empty, can leave the default role choice to us - .= (fromMaybe defaultRole <$> optFieldWithDocModifier "role" (description ?~ "Role of the invited user") schema) - <*> (.invitationId) - .= fieldWithDocModifier "id" (description ?~ "UUID used to refer the invitation") schema - <*> (.createdAt) - .= fieldWithDocModifier "created_at" (description ?~ "Timestamp of invitation creation") schema - <*> (.createdBy) - .= optFieldWithDocModifier "created_by" (description ?~ "ID of the inviting user") (maybeWithDefault A.Null schema) - <*> (.inviteeEmail) - .= fieldWithDocModifier "email" (description ?~ "Email of the invitee") schema - <*> (.inviteeName) - .= optFieldWithDocModifier "name" (description ?~ "Name of the invitee (1 - 128 characters)") (maybeWithDefault A.Null schema) - <*> (fmap (TE.decodeUtf8 . serializeURIRef') . inviteeUrl) - .= optFieldWithDocModifier "url" (description ?~ "URL of the invitation link to be sent to the invitee") (maybeWithDefault A.Null urlSchema) - where - urlSchema = parsedText "URIRef_Absolute" (runParser (uriParser strictURIParserOptions) . TE.encodeUtf8) + (description ?~ "An invitation to join a team on Wire. If invitee is invited from an existing personal account, inviter email is included.") + invitationObjectSchema + +invitationObjectSchema :: ObjectSchema SwaggerDoc Invitation +invitationObjectSchema = + Invitation + <$> (.team) + .= fieldWithDocModifier "team" (description ?~ "Team ID of the inviting team") schema + <*> (.role) + -- clients, when leaving "role" empty, can leave the default role choice to us + .= (fromMaybe defaultRole <$> optFieldWithDocModifier "role" (description ?~ "Role of the invited user") schema) + <*> (.invitationId) + .= fieldWithDocModifier "id" (description ?~ "UUID used to refer the invitation") schema + <*> (.createdAt) + .= fieldWithDocModifier "created_at" (description ?~ "Timestamp of invitation creation") schema + <*> (.createdBy) + .= optFieldWithDocModifier "created_by" (description ?~ "ID of the inviting user") (maybeWithDefault A.Null schema) + <*> (.inviteeEmail) + .= fieldWithDocModifier "email" (description ?~ "Email of the invitee") schema + <*> (.inviteeName) + .= optFieldWithDocModifier "name" (description ?~ "Name of the invitee (1 - 128 characters)") (maybeWithDefault A.Null schema) + <*> (fmap (TE.decodeUtf8 . serializeURIRef') . (.inviteeUrl)) + .= optFieldWithDocModifier "url" (description ?~ "URL of the invitation link to be sent to the invitee") (maybeWithDefault A.Null urlSchema) + where + urlSchema = parsedText "URIRef_Absolute" (runParser (uriParser strictURIParserOptions) . TE.encodeUtf8) newtype InvitationLocation = InvitationLocation { unInvitationLocation :: ByteString @@ -175,10 +193,8 @@ instance ToSchema InvitationList where schema = objectWithDocModifier "InvitationList" (description ?~ "A list of sent team invitations.") $ InvitationList - <$> ilInvitations - .= field "invitations" (array schema) - <*> ilHasMore - .= fieldWithDocModifier "has_more" (description ?~ "Indicator that the server has more invitations than returned.") schema + <$> ilInvitations .= field "invitations" (array schema) + <*> ilHasMore .= fieldWithDocModifier "has_more" (description ?~ "Indicator that the server has more invitations than returned.") schema -------------------------------------------------------------------------------- -- AcceptTeamInvitation @@ -196,3 +212,18 @@ instance ToSchema AcceptTeamInvitation where AcceptTeamInvitation <$> code .= fieldWithDocModifier "code" (description ?~ "Invitation code to accept.") schema <*> password .= fieldWithDocModifier "password" (description ?~ "The user account password.") schema + +data InvitationUserView = InvitationUserView + { invitation :: Invitation, + inviterEmail :: Maybe EmailAddress + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform InvitationUserView) + deriving (A.FromJSON, A.ToJSON, S.ToSchema) via (Schema InvitationUserView) + +instance ToSchema InvitationUserView where + schema = + object "InvitationUserView" $ + InvitationUserView + <$> invitation .= invitationObjectSchema + <*> inviterEmail .= maybe_ (optField "created_by_email" schema) diff --git a/libs/wire-api/src/Wire/API/User.hs b/libs/wire-api/src/Wire/API/User.hs index 25cf9e88172..771cd4ee893 100644 --- a/libs/wire-api/src/Wire/API/User.hs +++ b/libs/wire-api/src/Wire/API/User.hs @@ -1305,8 +1305,6 @@ newTeamUserTeamId = \case data BindingNewTeamUser = BindingNewTeamUser { bnuTeam :: NewTeam, bnuCurrency :: Maybe Currency.Alpha - -- FUTUREWORK: - -- Remove Currency selection once billing supports currency changes after team creation } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform BindingNewTeamUser) @@ -1517,7 +1515,6 @@ instance (res ~ ChangePhoneResponses) => AsUnion res (Maybe ChangePhoneError) wh data RemoveIdentityError = LastIdentity - | NoPassword | NoIdentity deriving (Generic) deriving (AsUnion RemoveIdentityErrorResponses) via GenericAsUnion RemoveIdentityErrorResponses RemoveIdentityError @@ -1526,7 +1523,6 @@ instance GSOP.Generic RemoveIdentityError type RemoveIdentityErrorResponses = [ ErrorResponse 'E.LastIdentity, - ErrorResponse 'E.NoPassword, ErrorResponse 'E.NoIdentity ] @@ -1584,6 +1580,7 @@ instance ToSchema NameUpdate where data ChangeEmailResponse = ChangeEmailResponseIdempotent | ChangeEmailResponseNeedsActivation + deriving (Eq, Show) instance AsUnion diff --git a/libs/wire-api/src/Wire/API/User/Activation.hs b/libs/wire-api/src/Wire/API/User/Activation.hs index 5e347a54afe..84c993870b4 100644 --- a/libs/wire-api/src/Wire/API/User/Activation.hs +++ b/libs/wire-api/src/Wire/API/User/Activation.hs @@ -32,6 +32,9 @@ module Wire.API.User.Activation -- * SendActivationCode SendActivationCode (..), + + -- * Activation + Activation (..), ) where @@ -211,3 +214,12 @@ instance ToSchema SendActivationCode where objectDesc = description ?~ "Data for requesting an email code to be sent. 'email' must be present." + +-- | The information associated with the pending activation of an 'EmailKey'. +data Activation = Activation + { -- | An opaque key for the original 'EmailKey' pending activation. + activationKey :: !ActivationKey, + -- | The confidential activation code. + activationCode :: !ActivationCode + } + deriving (Eq, Show) diff --git a/libs/wire-api/src/Wire/API/User/Client.hs b/libs/wire-api/src/Wire/API/User/Client.hs index 683417c0d6b..7c54c973391 100644 --- a/libs/wire-api/src/Wire/API/User/Client.hs +++ b/libs/wire-api/src/Wire/API/User/Client.hs @@ -157,6 +157,17 @@ instance ToSchema ClientCapability where element "legalhold-implicit-consent" ClientSupportsLegalholdImplicitConsent <> element "consumable-notifications" ClientSupportsConsumableNotifications +data ClientCapabilityV7 = ClientSupportsLegalholdImplicitConsentV7 + deriving (Eq) + +capabilitySchemaV7 :: ValueSchema NamedSwaggerDoc ClientCapabilityV7 +capabilitySchemaV7 = + enum @Text "ClientCapabilityV7" $ + element "legalhold-implicit-consent" ClientSupportsLegalholdImplicitConsentV7 + +clientCapabilityFromV7 :: ClientCapabilityV7 -> ClientCapability +clientCapabilityFromV7 ClientSupportsLegalholdImplicitConsentV7 = ClientSupportsLegalholdImplicitConsent + instance C.Cql ClientCapability where ctype = C.Tagged C.IntColumn @@ -181,23 +192,35 @@ instance ToSchema ClientCapabilityList where instance ToSchema (Versioned V6 ClientCapabilityList) where schema = - object "ClientCapabilityListV6" $ + object "ClientCapabilityListV6Wrapper" $ Versioned <$> unVersioned .= field "capabilities" (capabilitiesSchema (Just V6)) +instance ToSchema (Versioned V7 ClientCapabilityList) where + schema = + Versioned + <$> unVersioned .= capabilitiesSchema (Just V7) + capabilitiesSchema :: Maybe Version -> ValueSchema NamedSwaggerDoc ClientCapabilityList capabilitiesSchema mVersion = - named "ClientCapabilityList" $ + named (versionedName mVersion "ClientCapabilityList") $ ClientCapabilityList - <$> (Set.toList . dropIncompatibleCapabilities . fromClientCapabilityList) .= (Set.fromList <$> array schema) + <$> (Set.toList . fromClientCapabilityList) .= (Set.fromList <$> listSchema) where - dropIncompatibleCapabilities :: Set ClientCapability -> Set ClientCapability - dropIncompatibleCapabilities caps = + listSchema :: ValueSchema SwaggerDoc [ClientCapability] + listSchema = case mVersion of - Just v | v <= V6 -> Set.delete ClientSupportsConsumableNotifications caps - _ -> caps + Just v + | v <= V7 -> + map clientCapabilityFromV7 + <$> mapMaybe toCapabilityV7 .= array (capabilitySchemaV7) + _ -> array schema + + toCapabilityV7 :: ClientCapability -> Maybe ClientCapabilityV7 + toCapabilityV7 ClientSupportsConsumableNotifications = Nothing + toCapabilityV7 ClientSupportsLegalholdImplicitConsent = Just ClientSupportsLegalholdImplicitConsentV7 -------------------------------------------------------------------------------- -- UserClientMap @@ -511,7 +534,7 @@ mlsPublicKeysSchema = clientSchema :: Maybe Version -> ValueSchema NamedSwaggerDoc Client clientSchema mVersion = - object "Client" $ + object (versionedName mVersion "Client") $ Client <$> clientId .= field "id" schema <*> clientType .= field "type" schema @@ -530,6 +553,8 @@ clientSchema mVersion = Just v | v <= V6 -> dimap Versioned unVersioned $ schema @(Versioned V6 ClientCapabilityList) + | v == V7 -> + dimap Versioned unVersioned $ schema @(Versioned V7 ClientCapabilityList) _ -> schema @ClientCapabilityList instance ToSchema Client where @@ -538,11 +563,20 @@ instance ToSchema Client where instance ToSchema (Versioned 'V6 Client) where schema = Versioned <$> unVersioned .= clientSchema (Just V6) +instance ToSchema (Versioned 'V7 Client) where + schema = Versioned <$> unVersioned .= clientSchema (Just V7) + instance {-# OVERLAPPING #-} ToSchema (Versioned 'V6 [Client]) where schema = Versioned <$> unVersioned - .= named "ClientList" (array (clientSchema (Just V6))) + .= named "ClientListV6" (array (clientSchema (Just V6))) + +instance {-# OVERLAPPING #-} ToSchema (Versioned 'V7 [Client]) where + schema = + Versioned + <$> unVersioned + .= named "ClientListV7" (array (clientSchema (Just V7))) mlsPublicKeysFieldSchema :: ObjectSchema SwaggerDoc MLSPublicKeys mlsPublicKeysFieldSchema = fromMaybe mempty <$> optField "mls_public_keys" mlsPublicKeysSchema @@ -682,74 +716,83 @@ data NewClient = NewClient deriving (Arbitrary) via (GenericUniform NewClient) deriving (FromJSON, ToJSON, Swagger.ToSchema) via Schema NewClient +newClientSchema :: Maybe Version -> ValueSchema NamedSwaggerDoc NewClient +newClientSchema mVersion = + object "NewClient" $ + NewClient + <$> newClientPrekeys + .= fieldWithDocModifier + "prekeys" + (description ?~ "Prekeys for other clients to establish OTR sessions.") + (array schema) + <*> newClientLastKey + .= fieldWithDocModifier + "lastkey" + ( description + ?~ "The last resort prekey for other clients to establish OTR sessions. \ + \This key must have the ID 0xFFFF and is never deleted." + ) + schema + <*> newClientType + .= fieldWithDocModifier + "type" + ( description + ?~ "The type of client to register. A user may have no more than \ + \7 (seven) permanent clients and 1 (one) temporary client. When the \ + \limit of permanent clients is reached, an error is returned. \ + \When a temporary client already exists, it is replaced." + ) + schema + <*> newClientLabel .= maybe_ (optField "label" schema) + <*> newClientClass + .= maybe_ + ( optFieldWithDocModifier + "class" + ( description + ?~ "The device class this client belongs to. \ + \Either 'phone', 'tablet', or 'desktop'." + ) + schema + ) + <*> newClientCookie + .= maybe_ + ( optFieldWithDocModifier + "cookie" + (description ?~ "The cookie label, i.e. the label used when logging in.") + schema + ) + <*> newClientPassword + .= maybe_ + ( optFieldWithDocModifier + "password" + ( description + ?~ "The password of the authenticated user for verification. \ + \Note: Required for registration of the 2nd, 3rd, ... client." + ) + schema + ) + <*> newClientModel .= maybe_ (optField "model" schema) + <*> newClientCapabilities + .= maybe_ + ( optFieldWithDocModifier + "capabilities" + ( description + ?~ "Hints provided by the client for the backend so it can \ + \behave in a backwards-compatible way." + ) + (capabilitiesSchema mVersion) + ) + <*> newClientMLSPublicKeys .= mlsPublicKeysFieldSchema + <*> newClientVerificationCode .= maybe_ (optField "verification_code" schema) + instance ToSchema NewClient where - schema = - object "NewClient" $ - NewClient - <$> newClientPrekeys - .= fieldWithDocModifier - "prekeys" - (description ?~ "Prekeys for other clients to establish OTR sessions.") - (array schema) - <*> newClientLastKey - .= fieldWithDocModifier - "lastkey" - ( description - ?~ "The last resort prekey for other clients to establish OTR sessions. \ - \This key must have the ID 0xFFFF and is never deleted." - ) - schema - <*> newClientType - .= fieldWithDocModifier - "type" - ( description - ?~ "The type of client to register. A user may have no more than \ - \7 (seven) permanent clients and 1 (one) temporary client. When the \ - \limit of permanent clients is reached, an error is returned. \ - \When a temporary client already exists, it is replaced." - ) - schema - <*> newClientLabel .= maybe_ (optField "label" schema) - <*> newClientClass - .= maybe_ - ( optFieldWithDocModifier - "class" - ( description - ?~ "The device class this client belongs to. \ - \Either 'phone', 'tablet', or 'desktop'." - ) - schema - ) - <*> newClientCookie - .= maybe_ - ( optFieldWithDocModifier - "cookie" - (description ?~ "The cookie label, i.e. the label used when logging in.") - schema - ) - <*> newClientPassword - .= maybe_ - ( optFieldWithDocModifier - "password" - ( description - ?~ "The password of the authenticated user for verification. \ - \Note: Required for registration of the 2nd, 3rd, ... client." - ) - schema - ) - <*> newClientModel .= maybe_ (optField "model" schema) - <*> newClientCapabilities - .= maybe_ - ( optFieldWithDocModifier - "capabilities" - ( description - ?~ "Hints provided by the client for the backend so it can \ - \behave in a backwards-compatible way." - ) - schema - ) - <*> newClientMLSPublicKeys .= mlsPublicKeysFieldSchema - <*> newClientVerificationCode .= maybe_ (optField "verification_code" schema) + schema = newClientSchema Nothing + +instance ToSchema (Versioned 'V6 NewClient) where + schema = Versioned <$> unVersioned .= newClientSchema (Just V6) + +instance ToSchema (Versioned 'V7 NewClient) where + schema = Versioned <$> unVersioned .= newClientSchema (Just V7) newClient :: ClientType -> LastPrekey -> NewClient newClient t k = @@ -792,39 +835,48 @@ defUpdateClient = updateClientMLSPublicKeys = mempty } +updateClientSchema :: Maybe Version -> ValueSchema NamedSwaggerDoc UpdateClient +updateClientSchema mVersion = + object "UpdateClient" $ + UpdateClient + <$> updateClientPrekeys + .= ( fromMaybe [] + <$> optFieldWithDocModifier + "prekeys" + (description ?~ "New prekeys for other clients to establish OTR sessions.") + (array schema) + ) + <*> updateClientLastKey + .= maybe_ + ( optFieldWithDocModifier + "lastkey" + (description ?~ "New last-resort prekey.") + schema + ) + <*> updateClientLabel + .= maybe_ + ( optFieldWithDocModifier + "label" + (description ?~ "A new name for this client.") + schema + ) + <*> updateClientCapabilities + .= maybe_ + ( optFieldWithDocModifier + "capabilities" + (description ?~ "Hints provided by the client for the backend so it can behave in a backwards-compatible way.") + (capabilitiesSchema mVersion) + ) + <*> updateClientMLSPublicKeys .= mlsPublicKeysFieldSchema + instance ToSchema UpdateClient where - schema = - object "UpdateClient" $ - UpdateClient - <$> updateClientPrekeys - .= ( fromMaybe [] - <$> optFieldWithDocModifier - "prekeys" - (description ?~ "New prekeys for other clients to establish OTR sessions.") - (array schema) - ) - <*> updateClientLastKey - .= maybe_ - ( optFieldWithDocModifier - "lastkey" - (description ?~ "New last-resort prekey.") - schema - ) - <*> updateClientLabel - .= maybe_ - ( optFieldWithDocModifier - "label" - (description ?~ "A new name for this client.") - schema - ) - <*> updateClientCapabilities - .= maybe_ - ( optFieldWithDocModifier - "capabilities" - (description ?~ "Hints provided by the client for the backend so it can behave in a backwards-compatible way.") - schema - ) - <*> updateClientMLSPublicKeys .= mlsPublicKeysFieldSchema + schema = updateClientSchema Nothing + +instance ToSchema (Versioned 'V6 UpdateClient) where + schema = Versioned <$> unVersioned .= updateClientSchema (Just V6) + +instance ToSchema (Versioned 'V7 UpdateClient) where + schema = Versioned <$> unVersioned .= updateClientSchema (Just V7) -------------------------------------------------------------------------------- -- RmClient diff --git a/libs/wire-api/src/Wire/API/User/Identity.hs b/libs/wire-api/src/Wire/API/User/Identity.hs index 65b6a5ede61..841f6dc025d 100644 --- a/libs/wire-api/src/Wire/API/User/Identity.hs +++ b/libs/wire-api/src/Wire/API/User/Identity.hs @@ -22,6 +22,7 @@ module Wire.API.User.Identity ( -- * UserIdentity UserIdentity (..), + isUserSSOId, isSSOIdentity, newIdentity, emailIdentity, @@ -150,6 +151,10 @@ data UserSSOId deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform UserSSOId) +isUserSSOId :: UserSSOId -> Bool +isUserSSOId (UserSSOId _) = True +isUserSSOId (UserScimExternalId _) = False + instance C.Cql UserSSOId where ctype = C.Tagged C.TextColumn diff --git a/libs/wire-api/src/Wire/API/User/IdentityProvider.hs b/libs/wire-api/src/Wire/API/User/IdentityProvider.hs index 7283333f7a2..110696a925e 100644 --- a/libs/wire-api/src/Wire/API/User/IdentityProvider.hs +++ b/libs/wire-api/src/Wire/API/User/IdentityProvider.hs @@ -49,6 +49,7 @@ import Wire.Arbitrary (Arbitrary, GenericUniform (GenericUniform)) -- | The identity provider type used in Spar. type IdP = IdPConfig WireIdP +-- | Unique human-readable IdP name. newtype IdPHandle = IdPHandle {unIdPHandle :: Text} deriving (Eq, Ord, Show, FromJSON, ToJSON, ToSchema, Arbitrary, Generic) diff --git a/libs/wire-api/src/Wire/API/User/Orphans.hs b/libs/wire-api/src/Wire/API/User/Orphans.hs index 316889c115a..bfaaff37b40 100644 --- a/libs/wire-api/src/Wire/API/User/Orphans.hs +++ b/libs/wire-api/src/Wire/API/User/Orphans.hs @@ -26,8 +26,10 @@ import Data.Char import Data.Currency qualified as Currency import Data.ISO3166_CountryCodes import Data.LanguageCodes -import Data.OpenApi +import Data.OpenApi as O import Data.Proxy +import Data.Schema as S +import Data.Text qualified as T import Data.UUID import Data.X509 as X509 import Imports @@ -42,9 +44,9 @@ deriving instance Generic ISO639_1 -- Swagger instances -instance ToSchema ISO639_1 +instance O.ToSchema ISO639_1 -instance ToSchema CountryCode +instance O.ToSchema CountryCode -- FUTUREWORK: push orphans upstream to saml2-web-sso, servant-multipart -- FUTUREWORK: maybe avoid orphans altogether by defining schema instances manually @@ -69,19 +71,19 @@ samlSchemaOptions = -- This type comes from a seperate repo, so we're keeping the prefix dropping -- for the moment. -instance ToSchema SAML.XmlText where +instance O.ToSchema SAML.XmlText where declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions instance ToParamSchema SAML.IdPId where toParamSchema _ = toParamSchema (Proxy @UUID) -instance ToSchema SAML.AuthnRequest where +instance O.ToSchema SAML.AuthnRequest where declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions -instance ToSchema SAML.NameIdPolicy where +instance O.ToSchema SAML.NameIdPolicy where declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions -instance ToSchema SAML.NameIDFormat where +instance O.ToSchema SAML.NameIDFormat where declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions -- The generic schema breaks on this type, so we define it by hand. @@ -92,7 +94,7 @@ instance ToSchema SAML.NameIDFormat where -- and this results in an array whose underlying type which is at the same time -- marked as a string, and referring to the schema for AuthnRequest, which is of -- course invalid. -instance ToSchema (SAML.FormRedirect SAML.AuthnRequest) where +instance O.ToSchema (SAML.FormRedirect SAML.AuthnRequest) where declareNamedSchema _ = do authnReqSchema <- declareSchemaRef (Proxy @SAML.AuthnRequest) pure $ @@ -102,45 +104,58 @@ instance ToSchema (SAML.FormRedirect SAML.AuthnRequest) where & properties . at "uri" ?~ Inline (toSchema (Proxy @Text)) & properties . at "xml" ?~ authnReqSchema -instance ToSchema (SAML.ID SAML.AuthnRequest) where +instance O.ToSchema (SAML.ID SAML.AuthnRequest) where declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions { datatypeNameModifier = const "Id_AuthnRequest" } -instance ToSchema SAML.Time where +instance O.ToSchema SAML.Time where declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions -instance ToSchema SAML.SPMetadata where +instance O.ToSchema SAML.SPMetadata where declareNamedSchema _ = declareNamedSchema (Proxy @String) -instance ToSchema Void where +instance O.ToSchema Void where declareNamedSchema _ = declareNamedSchema (Proxy @String) instance (HasOpenApi route) => HasOpenApi (SM.MultipartForm SM.Mem resp :> route) where toOpenApi _proxy = toOpenApi (Proxy @route) -instance ToSchema SAML.IdPId where +instance O.ToSchema SAML.IdPId where declareNamedSchema _ = declareNamedSchema (Proxy @UUID) -instance (ToSchema a) => ToSchema (SAML.IdPConfig a) where +instance (O.ToSchema a) => O.ToSchema (SAML.IdPConfig a) where declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions -instance ToSchema SAML.Issuer where +instance O.ToSchema SAML.Issuer where declareNamedSchema _ = declareNamedSchema (Proxy @String) -instance ToSchema URI where +instance O.ToSchema URI where declareNamedSchema _ = declareNamedSchema (Proxy @String) -instance ToParamSchema URI where +instance O.ToParamSchema URI where toParamSchema _ = toParamSchema (Proxy @String) -instance ToSchema X509.SignedCertificate where +instance O.ToSchema X509.SignedCertificate where declareNamedSchema _ = declareNamedSchema (Proxy @String) -instance ToSchema SAML.IdPMetadata where +instance O.ToSchema SAML.IdPMetadata where declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions -instance ToSchema Currency.Alpha where - declareNamedSchema = genericDeclareNamedSchema defaultSchemaOptions +instance S.ToSchema Currency.Alpha where + schema = S.enum @Text "Currency.Alpha" cases & S.doc' . O.schema %~ swaggerTweaks + where + cases :: SchemaP [A.Value] Text (Alt Maybe Text) Currency.Alpha Currency.Alpha + cases = mconcat ((\cur -> S.element (T.pack (show cur)) cur) <$> [minBound @Currency.Alpha ..]) + + swaggerTweaks :: O.Schema -> O.Schema + swaggerTweaks = + ( O.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." + ) + . (O.example ?~ "EUR") + +deriving via (S.Schema Currency.Alpha) instance O.ToSchema Currency.Alpha diff --git a/libs/wire-api/src/Wire/API/User/Scim.hs b/libs/wire-api/src/Wire/API/User/Scim.hs index 07c07c3beea..fdb8e1396ff 100644 --- a/libs/wire-api/src/Wire/API/User/Scim.hs +++ b/libs/wire-api/src/Wire/API/User/Scim.hs @@ -182,7 +182,7 @@ instance ToSchema ScimTokenInfo where <*> (.stiName) .= field "name" schema -- | Metadata that we store about each token. -data ScimTokenInfoV6 = ScimTokenInfoV6 +data ScimTokenInfoV7 = ScimTokenInfoV7 { -- | Which team can be managed with the token stiTeam :: !TeamId, -- | Token ID, can be used to eg. delete the token @@ -196,13 +196,13 @@ data ScimTokenInfoV6 = ScimTokenInfoV6 stiDescr :: !Text } deriving (Eq, Show, Generic) - deriving (Arbitrary) via (GenericUniform ScimTokenInfoV6) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema.Schema ScimTokenInfoV6) + deriving (Arbitrary) via (GenericUniform ScimTokenInfoV7) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema.Schema ScimTokenInfoV7) -instance ToSchema ScimTokenInfoV6 where +instance ToSchema ScimTokenInfoV7 where schema = - object "ScimTokenInfoV6" $ - ScimTokenInfoV6 + object "ScimTokenInfoV7" $ + ScimTokenInfoV7 <$> (.stiTeam) .= field "team" schema <*> (.stiId) .= field "id" schema <*> (.stiCreatedAt) .= field "created_at" utcTimeSchema @@ -424,26 +424,38 @@ data CreateScimToken = CreateScimToken -- | User code (sent by email), for 2nd factor to 'password' verificationCode :: !(Maybe Code.Value), -- | Optional name for the token - name :: Maybe Text + name :: Maybe Text, + -- | Optional IdP that created users will "belong" to + idp :: Maybe SAML.IdPId } deriving (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform CreateScimToken) deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema.Schema CreateScimToken) createScimTokenSchema :: Maybe Version -> ValueSchema NamedSwaggerDoc CreateScimToken -createScimTokenSchema v = - object ("CreateScimToken" <> foldMap (Text.toUpper . versionText) v) $ +createScimTokenSchema mVersion = + object ("CreateScimToken" <> foldMap (Text.toUpper . versionText) mVersion) $ CreateScimToken <$> (.description) .= field "description" schema <*> password .= optField "password" (maybeWithDefault A.Null schema) <*> verificationCode .= optField "verification_code" (maybeWithDefault A.Null schema) - <*> (if isJust v then const Nothing else (.name)) .= maybe_ (optField "name" schema) + <*> nameSchema + <*> idpSchema + where + nameSchema = + case mVersion of + Just v | v <= V7 -> const Nothing .= pure Nothing + _ -> (.name) .= maybe_ (optField "name" schema) + idpSchema = + case mVersion of + Just v | v <= V7 -> const Nothing .= pure Nothing + _ -> (fmap SAML.fromIdPId . idp) .= maybe_ (optField "idp" (SAML.IdPId <$> uuidSchema)) instance ToSchema CreateScimToken where schema = createScimTokenSchema Nothing -instance ToSchema (Versioned 'V6 CreateScimToken) where - schema = Versioned <$> unVersioned .= createScimTokenSchema (Just V6) +instance ToSchema (Versioned 'V7 CreateScimToken) where + schema = Versioned <$> unVersioned .= createScimTokenSchema (Just V7) -- | Type used for the response of 'APIScimTokenCreate'. data CreateScimTokenResponse = CreateScimTokenResponse @@ -461,18 +473,18 @@ instance ToSchema CreateScimTokenResponse where <$> (.token) .= field "token" schema <*> (.info) .= field "info" schema -data CreateScimTokenResponseV6 = CreateScimTokenResponseV6 +data CreateScimTokenResponseV7 = CreateScimTokenResponseV7 { token :: ScimToken, - info :: ScimTokenInfoV6 + info :: ScimTokenInfoV7 } deriving (Eq, Show, Generic) - deriving (Arbitrary) via (GenericUniform CreateScimTokenResponseV6) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema.Schema CreateScimTokenResponseV6) + deriving (Arbitrary) via (GenericUniform CreateScimTokenResponseV7) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema.Schema CreateScimTokenResponseV7) -instance ToSchema CreateScimTokenResponseV6 where +instance ToSchema CreateScimTokenResponseV7 where schema = - object "CreateScimTokenResponseV6" $ - CreateScimTokenResponseV6 + object "CreateScimTokenResponseV7" $ + CreateScimTokenResponseV7 <$> (.token) .= field "token" schema <*> (.info) .= field "info" schema @@ -489,14 +501,14 @@ data ScimTokenList = ScimTokenList instance ToSchema ScimTokenList where schema = object "ScimTokenList" $ ScimTokenList <$> (.scimTokenListTokens) .= field "tokens" (array schema) -data ScimTokenListV6 = ScimTokenListV6 - { scimTokenListTokens :: [ScimTokenInfoV6] +data ScimTokenListV7 = ScimTokenListV7 + { scimTokenListTokens :: [ScimTokenInfoV7] } deriving (Eq, Show) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema.Schema ScimTokenListV6) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema.Schema ScimTokenListV7) -instance ToSchema ScimTokenListV6 where - schema = object "ScimTokenListV6" $ ScimTokenListV6 <$> (.scimTokenListTokens) .= field "tokens" (array schema) +instance ToSchema ScimTokenListV7 where + schema = object "ScimTokenListV7" $ ScimTokenListV7 <$> (.scimTokenListTokens) .= field "tokens" (array schema) newtype ScimTokenName = ScimTokenName {fromScimTokenName :: Text} deriving (Eq, Show) 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 63fbe936877..b2ea3c5631f 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 @@ -1013,7 +1013,7 @@ tests = testObjects [(Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_1, "testObject_ClientClass_user_1.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_2, "testObject_ClientClass_user_2.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_3, "testObject_ClientClass_user_3.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_4, "testObject_ClientClass_user_4.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_5, "testObject_ClientClass_user_5.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_6, "testObject_ClientClass_user_6.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_7, "testObject_ClientClass_user_7.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_8, "testObject_ClientClass_user_8.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_9, "testObject_ClientClass_user_9.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_10, "testObject_ClientClass_user_10.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_11, "testObject_ClientClass_user_11.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_12, "testObject_ClientClass_user_12.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_13, "testObject_ClientClass_user_13.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_14, "testObject_ClientClass_user_14.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_15, "testObject_ClientClass_user_15.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_16, "testObject_ClientClass_user_16.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_17, "testObject_ClientClass_user_17.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_18, "testObject_ClientClass_user_18.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_19, "testObject_ClientClass_user_19.json"), (Test.Wire.API.Golden.Generated.ClientClass_user.testObject_ClientClass_user_20, "testObject_ClientClass_user_20.json")], testGroup "Golden: PubClient_user" $ testObjects [(Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_1, "testObject_PubClient_user_1.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_2, "testObject_PubClient_user_2.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_3, "testObject_PubClient_user_3.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_4, "testObject_PubClient_user_4.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_5, "testObject_PubClient_user_5.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_6, "testObject_PubClient_user_6.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_7, "testObject_PubClient_user_7.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_8, "testObject_PubClient_user_8.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_9, "testObject_PubClient_user_9.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_10, "testObject_PubClient_user_10.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_11, "testObject_PubClient_user_11.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_12, "testObject_PubClient_user_12.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_13, "testObject_PubClient_user_13.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_14, "testObject_PubClient_user_14.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_15, "testObject_PubClient_user_15.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_16, "testObject_PubClient_user_16.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_17, "testObject_PubClient_user_17.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_18, "testObject_PubClient_user_18.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_19, "testObject_PubClient_user_19.json"), (Test.Wire.API.Golden.Generated.PubClient_user.testObject_PubClient_user_20, "testObject_PubClient_user_20.json")], - testGroup "Golden: ClientV5_user" $ + testGroup "Golden: ClientV6_user" $ testObjects [(Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_1, "testObject_ClientV6_user_1.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_2, "testObject_ClientV6_user_2.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_3, "testObject_ClientV6_user_3.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_4, "testObject_ClientV6_user_4.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_5, "testObject_ClientV6_user_5.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_6, "testObject_ClientV6_user_6.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_7, "testObject_ClientV6_user_7.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_8, "testObject_ClientV6_user_8.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_9, "testObject_ClientV6_user_9.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_10, "testObject_ClientV6_user_10.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_11, "testObject_ClientV6_user_11.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_12, "testObject_ClientV6_user_12.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_13, "testObject_ClientV6_user_13.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_14, "testObject_ClientV6_user_14.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_15, "testObject_ClientV6_user_15.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_16, "testObject_ClientV6_user_16.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_17, "testObject_ClientV6_user_17.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_18, "testObject_ClientV6_user_18.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_19, "testObject_ClientV6_user_19.json"), (Versioned @'V6 Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_20, "testObject_ClientV6_user_20.json")], testGroup "Golden: Client_user" $ testObjects [(Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_1, "testObject_Client_user_1.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_2, "testObject_Client_user_2.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_3, "testObject_Client_user_3.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_4, "testObject_Client_user_4.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_5, "testObject_Client_user_5.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_6, "testObject_Client_user_6.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_7, "testObject_Client_user_7.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_8, "testObject_Client_user_8.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_9, "testObject_Client_user_9.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_10, "testObject_Client_user_10.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_11, "testObject_Client_user_11.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_12, "testObject_Client_user_12.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_13, "testObject_Client_user_13.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_14, "testObject_Client_user_14.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_15, "testObject_Client_user_15.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_16, "testObject_Client_user_16.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_17, "testObject_Client_user_17.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_18, "testObject_Client_user_18.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_19, "testObject_Client_user_19.json"), (Test.Wire.API.Golden.Generated.Client_user.testObject_Client_user_20, "testObject_Client_user_20.json")], @@ -1293,7 +1293,28 @@ tests = testGroup "Golden: InvitationRequest_team" $ testObjects [(Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_1, "testObject_InvitationRequest_team_1.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_2, "testObject_InvitationRequest_team_2.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_3, "testObject_InvitationRequest_team_3.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_4, "testObject_InvitationRequest_team_4.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_5, "testObject_InvitationRequest_team_5.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_6, "testObject_InvitationRequest_team_6.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_7, "testObject_InvitationRequest_team_7.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_8, "testObject_InvitationRequest_team_8.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_9, "testObject_InvitationRequest_team_9.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_10, "testObject_InvitationRequest_team_10.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_11, "testObject_InvitationRequest_team_11.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_12, "testObject_InvitationRequest_team_12.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_13, "testObject_InvitationRequest_team_13.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_14, "testObject_InvitationRequest_team_14.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_15, "testObject_InvitationRequest_team_15.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_16, "testObject_InvitationRequest_team_16.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_17, "testObject_InvitationRequest_team_17.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_18, "testObject_InvitationRequest_team_18.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_19, "testObject_InvitationRequest_team_19.json"), (Test.Wire.API.Golden.Generated.InvitationRequest_team.testObject_InvitationRequest_team_20, "testObject_InvitationRequest_team_20.json")], testGroup "Golden: Invitation_team" $ - testObjects [(Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_1, "testObject_Invitation_team_1.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_2, "testObject_Invitation_team_2.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_3, "testObject_Invitation_team_3.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_4, "testObject_Invitation_team_4.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_5, "testObject_Invitation_team_5.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_6, "testObject_Invitation_team_6.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_7, "testObject_Invitation_team_7.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_8, "testObject_Invitation_team_8.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_9, "testObject_Invitation_team_9.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_10, "testObject_Invitation_team_10.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_11, "testObject_Invitation_team_11.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_12, "testObject_Invitation_team_12.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_13, "testObject_Invitation_team_13.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_14, "testObject_Invitation_team_14.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_15, "testObject_Invitation_team_15.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_16, "testObject_Invitation_team_16.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_17, "testObject_Invitation_team_17.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_18, "testObject_Invitation_team_18.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_19, "testObject_Invitation_team_19.json"), (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_20, "testObject_Invitation_team_20.json")], + testObjects + [ (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_1, "testObject_Invitation_team_1.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_2, "testObject_Invitation_team_2.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_3, "testObject_Invitation_team_3.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_4, "testObject_Invitation_team_4.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_5, "testObject_Invitation_team_5.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_6, "testObject_Invitation_team_6.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_7, "testObject_Invitation_team_7.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_8, "testObject_Invitation_team_8.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_9, "testObject_Invitation_team_9.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_10, "testObject_Invitation_team_10.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_11, "testObject_Invitation_team_11.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_12, "testObject_Invitation_team_12.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_13, "testObject_Invitation_team_13.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_14, "testObject_Invitation_team_14.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_15, "testObject_Invitation_team_15.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_16, "testObject_Invitation_team_16.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_17, "testObject_Invitation_team_17.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_18, "testObject_Invitation_team_18.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_19, "testObject_Invitation_team_19.json"), + (Test.Wire.API.Golden.Generated.Invitation_team.testObject_Invitation_team_20, "testObject_Invitation_team_20.json") + ], testGroup "Golden: InvitationList_team" $ testObjects [(Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_1, "testObject_InvitationList_team_1.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_2, "testObject_InvitationList_team_2.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_3, "testObject_InvitationList_team_3.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_4, "testObject_InvitationList_team_4.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_5, "testObject_InvitationList_team_5.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_6, "testObject_InvitationList_team_6.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_7, "testObject_InvitationList_team_7.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_8, "testObject_InvitationList_team_8.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_9, "testObject_InvitationList_team_9.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_10, "testObject_InvitationList_team_10.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_11, "testObject_InvitationList_team_11.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_12, "testObject_InvitationList_team_12.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_13, "testObject_InvitationList_team_13.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_14, "testObject_InvitationList_team_14.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_15, "testObject_InvitationList_team_15.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_16, "testObject_InvitationList_team_16.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_17, "testObject_InvitationList_team_17.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_18, "testObject_InvitationList_team_18.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_19, "testObject_InvitationList_team_19.json"), (Test.Wire.API.Golden.Generated.InvitationList_team.testObject_InvitationList_team_20, "testObject_InvitationList_team_20.json")], testGroup "Golden: NewLegalHoldService_team" $ diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/InvitationRequest_team.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/InvitationRequest_team.hs index f9f7c8ca50a..1987a7369a5 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/InvitationRequest_team.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/InvitationRequest_team.hs @@ -19,7 +19,7 @@ module Test.Wire.API.Golden.Generated.InvitationRequest_team where import Data.ISO3166_CountryCodes (CountryCode (BJ, FJ, GH, LB, ME, NL, OM, PA, TC, TZ)) import Data.LanguageCodes qualified (ISO639_1 (AF, AR, DA, DV, KJ, KS, KU, LG, NN, NY, OM, SI)) -import Imports (Maybe (Just, Nothing)) +import Imports import Wire.API.Locale import Wire.API.Team.Invitation (InvitationRequest (..)) import Wire.API.Team.Role (Role (RoleAdmin, RoleExternalPartner, RoleMember, RoleOwner)) @@ -32,7 +32,8 @@ testObject_InvitationRequest_team_1 = { locale = Just (Locale {lLanguage = Language Data.LanguageCodes.NN, lCountry = Nothing}), role = Just RoleOwner, inviteeName = Nothing, - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = True } testObject_InvitationRequest_team_2 :: InvitationRequest @@ -42,7 +43,8 @@ testObject_InvitationRequest_team_2 = Just (Locale {lLanguage = Language Data.LanguageCodes.AF, lCountry = Just (Country {fromCountry = GH})}), role = Nothing, inviteeName = Nothing, - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = False } testObject_InvitationRequest_team_3 :: InvitationRequest @@ -58,7 +60,8 @@ testObject_InvitationRequest_team_3 = "\27175\1085444\v\182035\144967G\189107\1042607\ETX\180573\1047918\ETX\1075522ZG\1087064\STX+i\46576Ux\FS\FS5\ESC\ae\10301\36223(3\1009347\\\t\EOT\v@\ENQs\r#R\136368G'N^?\NAKB\f\FS\NULx\1024041@\34031\1105463\1058551`A]@\34846\133788*\1025332N;\ETX\FSh\bS\US\US\SO`^qU<\21803\SYN\1094791\ETX\1112073M\SI\1019355\4619=zM[\181520\161190\n\SI}\ENQ\1008012\aaZI\18628\ACKE#G^t\148685\DLE\157774LY\182624\&6vt\\" } ), - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = False } testObject_InvitationRequest_team_4 :: InvitationRequest @@ -67,7 +70,8 @@ testObject_InvitationRequest_team_4 = { locale = Nothing, role = Just RoleMember, inviteeName = Nothing, - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = True } testObject_InvitationRequest_team_5 :: InvitationRequest @@ -76,7 +80,8 @@ testObject_InvitationRequest_team_5 = { locale = Nothing, role = Just RoleAdmin, inviteeName = Just (Name {fromName = "\171800\1076860\1103443\CAN8=\n;}\169054M\ao\v3+\n"}), - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = True } testObject_InvitationRequest_team_6 :: InvitationRequest @@ -92,7 +97,8 @@ testObject_InvitationRequest_team_6 = "\RSD[alw\RS\ACKP \999760\rO\175510'8\989959\1082925g W:8\v:-(`+\131521\ESC_\CAN\1105214\44926(\"&\DC2NZ\1082341\ACKS\SYNLOW|p\EM\194645\&1\175388" } ), - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = True } testObject_InvitationRequest_team_7 :: InvitationRequest @@ -101,7 +107,8 @@ testObject_InvitationRequest_team_7 = { locale = Nothing, role = Just RoleAdmin, inviteeName = Nothing, - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = True } testObject_InvitationRequest_team_8 :: InvitationRequest @@ -112,7 +119,8 @@ testObject_InvitationRequest_team_8 = role = Nothing, inviteeName = Just (Name {fromName = "\1036838&f\1104978\1021739j5\CANv]k\1034960\993099c[\1019257\1047325\EOTw.uL~/"}), - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = False } testObject_InvitationRequest_team_9 :: InvitationRequest @@ -123,7 +131,8 @@ testObject_InvitationRequest_team_9 = role = Just RoleAdmin, inviteeName = Just (Name {fromName = "|H\181717/%\RSu\1019619\&7V\142010\62451*G\SOHE\993531,\1015423WGtY\SYN*Nd\156695{Pl"}), - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = False } testObject_InvitationRequest_team_10 :: InvitationRequest @@ -139,7 +148,8 @@ testObject_InvitationRequest_team_10 = "H\1008404\RS\45861\92335uv\1045159\DC2\1045852\SUB \160164=a\ESC4H,B\CAN\1039540GpV0\1044935;_\NUL\173370Z\DC1\28376\NAK6\32784'W9z\11986\t\59610r\150374\1057016\SYN_ge\35917\EOTD\94732o\an>\993583" } ), - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = False } testObject_InvitationRequest_team_11 :: InvitationRequest @@ -154,7 +164,8 @@ testObject_InvitationRequest_team_11 = "\167004\41433\11577\74832h_5bb2}\46841\166935P\NUL\SOT*\US`b\170964\SI:4\n5\SUB\GS*T\1016149Bv\ESC\ETX\GS\1050773\175887Uu\r_\DLE)y\153990\EOT\b\US\DC4\FS\CAN?\1050027\149716\22398\NAK\SUB4\v 5\NULi\43113o=\tnG\37464\ETBiC\DC39\SOP\1026840\n\v\EM\SYNU\7800%\49334\DC2\USF\FS" } ), - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = False } testObject_InvitationRequest_team_12 :: InvitationRequest @@ -170,7 +181,8 @@ testObject_InvitationRequest_team_12 = "_\EM@\GS0\52658\1041209\1014911\FS\DLE\1100406!\1081838\SOc\US\NUL\SOH>\1074611\168456\EM\175538\&1}!h0\DLE\1053201w\EOT\1073681\&1aJ6c\GS\986890b\131925{\996638\131443\a\1094281" } ), - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = True } testObject_InvitationRequest_team_13 :: InvitationRequest @@ -185,7 +197,8 @@ testObject_InvitationRequest_team_13 = "C\990664+\1033671\n#s\1072813\FSpb\SOH\1015233\1073302\&1\ETBE_\CANj\EMV\US\1063126\15431\1099470lO8\ACK\1056562\FS\SYN\CAN\DLE6\137862-beR!s\48584\ETB\v\1049375\984016xt\SIRf~w\1030329\DEL+_\70046\&91:,\1034030#cf\1056279\3624\2548\6959B\"\1097722F\t\1109914\1069782/\DEL\DLE'\1004715*\171262\&7\156200w\1061410H\59715x\DC32\EMt\163668o6\DC4F%=t\1003324\1097336=\NUL\ENQA\1101771\1011923\NUL\EOT[i\992519@\b\FS\f" } ), - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = True } testObject_InvitationRequest_team_14 :: InvitationRequest @@ -195,7 +208,8 @@ testObject_InvitationRequest_team_14 = Just (Locale {lLanguage = Language Data.LanguageCodes.DV, lCountry = Just (Country {fromCountry = LB})}), role = Just RoleAdmin, inviteeName = Just (Name {fromName = "\NAKwGn\996611\149528\&1}\EOTgY.>=}"}), - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = True } testObject_InvitationRequest_team_15 :: InvitationRequest @@ -210,7 +224,8 @@ testObject_InvitationRequest_team_15 = "y\1104714\&5\1000317\710S\1019005\DC4\rH/_\DC3A\ETX\119343\&0w\GS?TQd*1&[?cHW}\21482\1021206\CAN\180566Q+\ETXmh\995371X\SO\ENQ\DC1^g\144398\bqrNV\SO\1095058WMe\a\ENQ" } ), - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = True } testObject_InvitationRequest_team_16 :: InvitationRequest @@ -220,7 +235,8 @@ testObject_InvitationRequest_team_16 = Just (Locale {lLanguage = Language Data.LanguageCodes.OM, lCountry = Just (Country {fromCountry = BJ})}), role = Just RoleAdmin, inviteeName = Nothing, - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = True } testObject_InvitationRequest_team_17 :: InvitationRequest @@ -230,7 +246,8 @@ testObject_InvitationRequest_team_17 = Just (Locale {lLanguage = Language Data.LanguageCodes.KJ, lCountry = Just (Country {fromCountry = TC})}), role = Just RoleExternalPartner, inviteeName = Nothing, - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = True } testObject_InvitationRequest_team_18 :: InvitationRequest @@ -245,7 +262,8 @@ testObject_InvitationRequest_team_18 = "8VPAp\137681\&2L<s\ACKt]\1051893\1028831G/\SIQb\1099332<\62973B\DC3\995191kJ&\1028424\DLE\a \66433\SO\987741\1099076$\99376\"u2g\ENQ[<.N;%\EMsm\43781*\1030957s\184809DsCowW-\1069896&EF=\\H\NAK,Z\rJ\ETBw-\STX\ahC`\1077061\52563\&1Ds^7Udh+e\fL Ld\ESCh&\1000121\1102718\1028691;\142313\a\985672Xp\26072\SOP\b\t\187311\1063310.\DEL\RSp" } ), - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = True } testObject_InvitationRequest_team_19 :: InvitationRequest @@ -260,7 +278,8 @@ testObject_InvitationRequest_team_19 = "kl\ETX\EOT\SYN%s7\1031959fX\994905A\b7\DC1\DELD\EOT\DC1\165155s\DELg)dD\157274Rx[\1026892Tw\68117\RS\SUB\1049684z\\\SI\ENQ\17054l\1089470l|oKc\\(\187173\1101164=\33052\&2VI*\1095067\&2oTh&#+;o\5017dXA\12103=*\1074686Q\1032360{\994965\917585\&5}\GS9D\186360\1064921r\1080854P:<!|\1002411\v4Pt1\983861g\b\STX\152876\rfY\135334$\DEL_\54841\"\1035381\&8" } ), - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = True } testObject_InvitationRequest_team_20 :: InvitationRequest @@ -275,5 +294,6 @@ testObject_InvitationRequest_team_20 = "N\1014949\3115qE\1086743,\1069753\1076493\&3-19bY\"Iz|BpQ\1112885\"\ACKdfC\1095189p\SO\1038198%-Z\SUB\1082854!Z\156657d\va\174302\ESC\b\ESCg\DELb\b\1009771\995646X}\STX\\^\1091690\&9\58052\1113953" } ), - inviteeEmail = unsafeEmailAddress "some" "example" + inviteeEmail = unsafeEmailAddress "some" "example", + allowExisting = False } diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Invitation_team.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Invitation_team.hs index c63ff90e0ee..16a84144a6d 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Invitation_team.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Invitation_team.hs @@ -20,7 +20,7 @@ module Test.Wire.API.Golden.Generated.Invitation_team where import Data.Id (Id (Id)) import Data.Json.Util (readUTCTimeMillis) import Data.UUID qualified as UUID (fromString) -import Imports (Maybe (Just, Nothing), fromJust) +import Imports import Wire.API.Team.Invitation (Invitation (..)) import Wire.API.Team.Role (Role (RoleAdmin, RoleExternalPartner, RoleMember, RoleOwner)) import Wire.API.User.Identity 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 3a898d764ce..3120bbdf928 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 @@ -39,6 +39,7 @@ import Test.Wire.API.Golden.Manual.FederationRestriction import Test.Wire.API.Golden.Manual.FederationStatus import Test.Wire.API.Golden.Manual.GetPaginatedConversationIds import Test.Wire.API.Golden.Manual.GroupId +import Test.Wire.API.Golden.Manual.InvitationUserView import Test.Wire.API.Golden.Manual.ListConversations import Test.Wire.API.Golden.Manual.ListUsersById import Test.Wire.API.Golden.Manual.LoginId_user @@ -120,7 +121,9 @@ tests = (testObject_ClientCapabilityList_2, "testObject_ClientCapabilityList_2.json") ], testGroup "ClientCapabilityListV6 - non-round-trip" $ - [testToJSON testObject_ClientCapabilityList_3 "testObject_ClientCapabilityList_3.json"], + [ testToJSON testObject_ClientCapabilityList_3 "testObject_ClientCapabilityList_3.json", + testToJSON testObject_ClientCapabilityList_3_V7 "testObject_ClientCapabilityList_3_V7.json" + ], testGroup "ClientCapabilityList" $ testObjects [ (testObject_ClientCapabilityList_4, "testObject_ClientCapabilityList_4.json"), @@ -311,5 +314,10 @@ tests = (testObject_Activate_user_2, "testObject_Activate_user_2.json"), (testObject_Activate_user_3, "testObject_Activate_user_3.json"), (testObject_Activate_user_4, "testObject_Activate_user_4.json") + ], + testGroup "InvitationUserView" $ + testObjects + [ (testObject_InvitationUserView_team_1, "testObject_InvitationUserView_team_1.json"), + (testObject_InvitationUserView_team_2, "testObject_InvitationUserView_team_2.json") ] ] diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ClientCapabilityList.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ClientCapabilityList.hs index bbb616c990a..4d75c97e80e 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ClientCapabilityList.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ClientCapabilityList.hs @@ -39,6 +39,16 @@ testObject_ClientCapabilityList_3 = ] ) +testObject_ClientCapabilityList_3_V7 :: Versioned V7 ClientCapabilityList +testObject_ClientCapabilityList_3_V7 = + Versioned $ + ClientCapabilityList + ( Set.fromList + [ ClientSupportsLegalholdImplicitConsent, + ClientSupportsConsumableNotifications + ] + ) + testObject_ClientCapabilityList_4 :: ClientCapabilityList testObject_ClientCapabilityList_4 = ClientCapabilityList mempty diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/CreateScimToken.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/CreateScimToken.hs index e2c32ffcf55..0e7e4059220 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/CreateScimToken.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/CreateScimToken.hs @@ -21,7 +21,9 @@ import Data.Code import Data.Misc (plainTextPassword6Unsafe) import Data.Range (unsafeRange) import Data.Text.Ascii (AsciiChars (validate)) +import Data.UUID qualified as UUID (fromString) import Imports +import SAML2.WebSSO qualified as SAML import Wire.API.User.Scim (CreateScimToken (..)) testObject_CreateScimToken_1 :: CreateScimToken @@ -31,6 +33,7 @@ testObject_CreateScimToken_1 = (Just (plainTextPassword6Unsafe "very-geheim")) (Just (Value {asciiValue = unsafeRange (fromRight undefined (validate "123456"))})) Nothing + Nothing testObject_CreateScimToken_2 :: CreateScimToken testObject_CreateScimToken_2 = @@ -39,6 +42,7 @@ testObject_CreateScimToken_2 = (Just (plainTextPassword6Unsafe "secret")) Nothing Nothing + (Just (SAML.IdPId (fromJust (UUID.fromString "c0d7af66-647d-41e6-8e7f-c1c387c91567")))) testObject_CreateScimToken_3 :: CreateScimToken testObject_CreateScimToken_3 = @@ -47,6 +51,7 @@ testObject_CreateScimToken_3 = Nothing (Just (Value {asciiValue = unsafeRange (fromRight undefined (validate "654321"))})) Nothing + Nothing testObject_CreateScimToken_4 :: CreateScimToken testObject_CreateScimToken_4 = @@ -55,3 +60,4 @@ testObject_CreateScimToken_4 = Nothing Nothing (Just "scim connection name") + Nothing diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/InvitationUserView.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/InvitationUserView.hs new file mode 100644 index 00000000000..be52d020ff9 --- /dev/null +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/InvitationUserView.hs @@ -0,0 +1,61 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2024 Wire Swiss GmbH <opensource@wire.com> +-- +-- 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 <https://www.gnu.org/licenses/>. + +module Test.Wire.API.Golden.Manual.InvitationUserView where + +import Data.Id (Id (Id)) +import Data.Json.Util (readUTCTimeMillis) +import Data.UUID qualified as UUID (fromString) +import Imports +import Wire.API.Team.Invitation +import Wire.API.Team.Role +import Wire.API.User.Identity +import Wire.API.User.Profile (Name (Name, fromName)) + +testObject_InvitationUserView_team_1 :: InvitationUserView +testObject_InvitationUserView_team_1 = + InvitationUserView + { invitation = + Invitation + { team = Id (fromJust (UUID.fromString "00000002-0000-0001-0000-000200000002")), + role = RoleAdmin, + invitationId = Id (fromJust (UUID.fromString "00000002-0000-0001-0000-000200000000")), + createdAt = fromJust (readUTCTimeMillis "1864-05-11T20:13:15.856Z"), + createdBy = Just (Id (fromJust (UUID.fromString "00000002-0000-0000-0000-000100000001"))), + inviteeEmail = unsafeEmailAddress "some" "example", + inviteeName = Nothing, + inviteeUrl = Nothing + }, + inviterEmail = Just $ unsafeEmailAddress "some" "example" + } + +testObject_InvitationUserView_team_2 :: InvitationUserView +testObject_InvitationUserView_team_2 = + InvitationUserView + { invitation = + Invitation + { team = Id (fromJust (UUID.fromString "00000000-0000-0001-0000-000000000000")), + role = RoleExternalPartner, + invitationId = Id (fromJust (UUID.fromString "00000002-0000-0001-0000-000100000002")), + createdAt = fromJust (readUTCTimeMillis "1864-05-12T14:47:35.551Z"), + createdBy = Just (Id (fromJust (UUID.fromString "00000002-0000-0001-0000-000200000001"))), + inviteeEmail = unsafeEmailAddress "some" "example", + inviteeName = Just (Name {fromName = "\1067847} 2pGEW+\rT\171609p\174643\157218&\146145v0\b"}), + inviteeUrl = Nothing + }, + inviterEmail = Nothing + } diff --git a/libs/wire-api/test/golden/testObject_ClientCapabilityList_3_V7.json b/libs/wire-api/test/golden/testObject_ClientCapabilityList_3_V7.json new file mode 100644 index 00000000000..36d36433693 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_ClientCapabilityList_3_V7.json @@ -0,0 +1,3 @@ +[ + "legalhold-implicit-consent" +] diff --git a/libs/wire-api/test/golden/testObject_CreateScimToken_2.json b/libs/wire-api/test/golden/testObject_CreateScimToken_2.json index 8364d591e30..dc68cd825a8 100644 --- a/libs/wire-api/test/golden/testObject_CreateScimToken_2.json +++ b/libs/wire-api/test/golden/testObject_CreateScimToken_2.json @@ -1,5 +1,6 @@ { "description": "description2", + "idp": "c0d7af66-647d-41e6-8e7f-c1c387c91567", "password": "secret", "verification_code": null } diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_1.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_1.json index 7948f9021bf..01304017a30 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_1.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_1.json @@ -1,4 +1,5 @@ { + "allow_existing": true, "email": "some@example", "locale": "nn", "name": null, diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_10.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_10.json index bf9390b4067..20babd28e68 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_10.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_10.json @@ -1,4 +1,5 @@ { + "allow_existing": false, "email": "some@example", "locale": "ny-OM", "name": "H󶌔\u001e댥𖢯uv󿊧\u0012󿕜\u001a 𧆤=a\u001b4H,B\u0018󽲴GpV0󿇇;_\u0000𪔺Z\u0011滘\u00156耐'W9z⻒\tr𤭦􂃸\u0016_ge豍\u0004D𗈌o\u0007n>󲤯", diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_11.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_11.json index 4d0cd645404..250cfa56be0 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_11.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_11.json @@ -1,4 +1,5 @@ { + "allow_existing": false, "email": "some@example", "locale": "si", "name": "𨱜ꇙⴹ𒑐h_5bb2}뛹𨰗P\u0000\u000eT*\u001f`b𩯔\u000f:4\n5\u001a\u001d*T󸅕Bv\u001b\u0003\u001d􀢕𪼏Uu\r_\u0010)y𥦆\u0004\u0008\u001f\u0014\u001c\u0018?􀖫𤣔坾\u0015\u001a4\u000b 5\u0000iꡩo=\tnG鉘\u0017iC\u00139\u000eP󺬘\n\u000b\u0019\u0016UṸ%삶\u0012\u001fF\u001c", diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_12.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_12.json index 4ea54084e75..4da71773da2 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_12.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_12.json @@ -1,4 +1,5 @@ { + "allow_existing": true, "email": "some@example", "locale": "ar-PA", "name": "_\u0019@\u001d0춲󾌹󷱿\u001c\u0010􌩶!􈇮\u000ec\u001f\u0000\u0001>􆖳𩈈\u0019𪶲1}!h0\u0010􁈑w\u0004􆈑1aJ6c\u001d󰼊b𠍕{󳔞𠅳\u0007􋊉", diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_13.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_13.json index af76a81bc0a..de0f1ee5167 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_13.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_13.json @@ -1,4 +1,5 @@ { + "allow_existing": true, "email": "some@example", "locale": null, "name": "C󱷈+󼗇\n#s􅺭\u001cpb\u0001󷷁􆂖1\u0017E_\u0018j\u0019V\u001f􃣖㱇􌛎lO8\u0006􁼲\u001c\u0016\u0018\u00106𡪆-beR!s뷈\u0017\u000b􀌟󰏐xt\u000fRf~w󻢹+_𑆞91:,󼜮#cf􁸗ศ৴ᬯB\"􋿺F\t􎾚􅋖/\u0010'󵒫*𩳾7𦈨w􃈢Hx\u00132\u0019t𧽔o6\u0014F%=t󴼼􋹸=\u0000\u0005A􌿋󷃓\u0000\u0004[i󲔇@\u0008\u001c\u000c", diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_14.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_14.json index 8aa099fd0d0..ab034bba36a 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_14.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_14.json @@ -1,4 +1,5 @@ { + "allow_existing": true, "email": "some@example", "locale": "dv-LB", "name": "\u0015wGn󳔃𤠘1}\u0004gY.>=}", diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_15.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_15.json index d4ac032590e..b5314fc4545 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_15.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_15.json @@ -1,4 +1,5 @@ { + "allow_existing": true, "email": "some@example", "locale": null, "name": "y􍭊5󴍽ˆS󸱽\u0014\rH/_\u0013A\u0003𝈯0w\u001d?TQd*1&[?cHW}只󹔖\u0018𬅖Q+\u0003mh󳀫X\u000e\u0005\u0011^g𣐎\u0008qrNV\u000e􋖒WMe\u0007\u0005", diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_16.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_16.json index 89de798ef5c..110def35df3 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_16.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_16.json @@ -1,4 +1,5 @@ { + "allow_existing": true, "email": "some@example", "locale": "om-BJ", "name": null, diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_17.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_17.json index 8c154e43f07..0af527236d8 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_17.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_17.json @@ -1,4 +1,5 @@ { + "allow_existing": true, "email": "some@example", "locale": "kj-TC", "name": null, diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_18.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_18.json index 9021ab1aa62..96b1c3d619e 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_18.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_18.json @@ -1,4 +1,5 @@ { + "allow_existing": true, "email": "some@example", "locale": "ku", "name": "8VPAp𡧑2L<s\u0006t]􀳵󻋟G/\u000fQb􌙄<B\u0013󲽷kJ&󻅈\u0010\u0007 𐎁\u000e󱉝􌕄$𘐰\"u2g\u0005[<.N;%\u0019smꬅ*󻬭s𭇩DsCowW-􅍈&EF=\\H\u0015,Z\rJ\u0017w-\u0002\u0007hC`􆽅쵓1Ds^7Udh+e\u000cL Ld\u001bh&󴊹􍍾󻉓;𢯩\u0007󰩈Xp旘\u000eP\u0008\t𭮯􃦎.\u001ep", diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_19.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_19.json index 944bea2d184..1958b3cac01 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_19.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_19.json @@ -1,4 +1,5 @@ { + "allow_existing": true, "email": "some@example", "locale": null, "name": "kl\u0003\u0004\u0016%s7󻼗fX󲹙A\u00087\u0011D\u0004\u0011𨔣sg)dD𦙚Rx[󺭌Tw𐨕\u001e\u001a􀑔z\\\u000f\u0005䊞l􉾾l|oKc\\(𭬥􌵬=脜2VI*􋖛2oTh&#+;o᎙dXA⽇=*􆗾Q󼂨{󲺕󠁑5}\u001d9D𭟸􃿙r􇸖P:<!|󴮫\u000b4Pt1󰌵g\u0008\u0002𥔬\rfY𡂦$_혹\"󼱵8", diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_2.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_2.json index efd25d9379a..fa4cbc5866c 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_2.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_2.json @@ -1,4 +1,5 @@ { + "allow_existing": false, "email": "some@example", "locale": "af-GH", "name": null, diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_20.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_20.json index 7ad4d701667..8bee37fcc9c 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_20.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_20.json @@ -1,4 +1,5 @@ { + "allow_existing": false, "email": "some@example", "locale": null, "name": "N󷲥ఫqE􉔗,􅊹􆴍3-19bY\"Iz|BpQ􏬵\"\u0006dfC􋘕p\u000e󽝶%-Z\u001a􈗦!Z𦏱d\u000ba𪣞\u001b\u0008\u001bgb\u0008󶡫󳄾X}\u0002\\^􊡪9􏽡", diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_3.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_3.json index 1f2dcac0cdd..69f97c93b50 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_3.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_3.json @@ -1,4 +1,5 @@ { + "allow_existing": false, "email": "some@example", "locale": "lg-TZ", "name": "樧􉀄\u000b𬜓𣙇G𮊳󾢯\u0003𬅝󿵮\u0003􆥂ZG􉙘\u0002+i뗰Ux\u001c\u001c5\u001b\u0007e⠽赿(3󶛃\\\t\u0004\u000b@\u0005s\r#R𡒰G'N^?\u0015B\u000c\u001c\u0000x󺀩@蓯􍸷􂛷`A]@蠞𠪜*󺔴N;\u0003\u001ch\u0008S\u001f\u001f\u000e`^qU<唫\u0016􋒇\u0003􏠉M\u000f󸷛ላ=zM[𬔐𧖦\n\u000f}\u0005󶆌\u0007aZI䣄\u0006E#G^t𤓍\u0010𦡎LY𬥠6vt\\", diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_4.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_4.json index 43ef3a45c44..49b85d2375c 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_4.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_4.json @@ -1,4 +1,5 @@ { + "allow_existing": true, "email": "some@example", "locale": null, "name": null, diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_5.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_5.json index 8a5ab0bdb42..b6c084d5864 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_5.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_5.json @@ -1,4 +1,5 @@ { + "allow_existing": true, "email": "some@example", "locale": null, "name": "𩼘􆹼􍙓\u00188=\n;}𩑞M\u0007o\u000b3+\n", diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_6.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_6.json index 7da94e9cf7d..cc7bcce5452 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_6.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_6.json @@ -1,4 +1,5 @@ { + "allow_existing": true, "email": "some@example", "locale": "da-ME", "name": "\u001eD[alw\u001e\u0006P 󴅐\rO𪶖'8󱬇􈘭g W:8\u000b:-(`+𠇁\u001b_\u0018􍴾꽾(\"&\u0012NZ􈏥\u0006S\u0016LOW|p\u0019型1𪴜", diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_7.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_7.json index e19e7f5d8a0..176370df68c 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_7.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_7.json @@ -1,4 +1,5 @@ { + "allow_existing": true, "email": "some@example", "locale": null, "name": null, diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_8.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_8.json index 5d7f6a0858e..c35974eb74d 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_8.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_8.json @@ -1,4 +1,5 @@ { + "allow_existing": false, "email": "some@example", "locale": "ks-NL", "name": "󽈦&f􍱒󹜫j5\u0018v]k󼫐󲝋c[󸵹󿬝\u0004w.uL~/", diff --git a/libs/wire-api/test/golden/testObject_InvitationRequest_team_9.json b/libs/wire-api/test/golden/testObject_InvitationRequest_team_9.json index 87d8e19cf8b..11dad9d84ef 100644 --- a/libs/wire-api/test/golden/testObject_InvitationRequest_team_9.json +++ b/libs/wire-api/test/golden/testObject_InvitationRequest_team_9.json @@ -1,4 +1,5 @@ { + "allow_existing": false, "email": "some@example", "locale": "kj-FJ", "name": "|H𬗕/%\u001eu󸻣7V𢪺*G\u0001E󲣻,󷹿WGtY\u0016*Nd𦐗{Pl", diff --git a/libs/wire-api/test/golden/testObject_InvitationUserView_team_1.json b/libs/wire-api/test/golden/testObject_InvitationUserView_team_1.json new file mode 100644 index 00000000000..7932e3f38be --- /dev/null +++ b/libs/wire-api/test/golden/testObject_InvitationUserView_team_1.json @@ -0,0 +1,11 @@ +{ + "created_at": "1864-05-11T20:13:15.856Z", + "created_by": "00000002-0000-0000-0000-000100000001", + "created_by_email": "some@example", + "email": "some@example", + "id": "00000002-0000-0001-0000-000200000000", + "name": null, + "role": "admin", + "team": "00000002-0000-0001-0000-000200000002", + "url": null +} diff --git a/libs/wire-api/test/golden/testObject_InvitationUserView_team_2.json b/libs/wire-api/test/golden/testObject_InvitationUserView_team_2.json new file mode 100644 index 00000000000..c03242304f4 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_InvitationUserView_team_2.json @@ -0,0 +1,10 @@ +{ + "created_at": "1864-05-12T14:47:35.551Z", + "created_by": "00000002-0000-0001-0000-000200000001", + "email": "some@example", + "id": "00000002-0000-0001-0000-000100000002", + "name": "􄭇} 2pGEW+\rT𩹙p𪨳𦘢&𣫡v0\u0008", + "role": "partner", + "team": "00000000-0000-0001-0000-000000000000", + "url": null +} 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 ee312a10edf..787f4488813 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 @@ -38,6 +38,7 @@ import Wire.API.Conversation.Typing qualified as Conversation.Typing import Wire.API.CustomBackend qualified as CustomBackend 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.Locale qualified as Locale import Wire.API.Message qualified as Message @@ -338,6 +339,8 @@ tests = testRoundTrip @(User.Search.SearchResult User.Search.TeamContact), testRoundTrip @User.Search.PagingState, testRoundTrip @User.Search.TeamContact, + testRoundTrip @EventWebSocketProtocol.MessageServerToClient, + testRoundTrip @EventWebSocketProtocol.MessageClientToServer, testRoundTrip @(Wrapped.Wrapped "some_int" Int), testRoundTrip @Conversation.Action.SomeConversationAction, testRoundTrip @Routes.Version.Version, diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index 1b61c678a71..a4568c2aa27 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -101,6 +101,7 @@ library Wire.API.Event.Gundeck Wire.API.Event.LeaveReason Wire.API.Event.Team + Wire.API.Event.WebSocketProtocol Wire.API.FederationStatus Wire.API.FederationUpdate Wire.API.Internal.BulkPush @@ -595,6 +596,7 @@ test-suite wire-api-golden-tests Test.Wire.API.Golden.Manual.FederationStatus Test.Wire.API.Golden.Manual.GetPaginatedConversationIds Test.Wire.API.Golden.Manual.GroupId + Test.Wire.API.Golden.Manual.InvitationUserView Test.Wire.API.Golden.Manual.ListConversations Test.Wire.API.Golden.Manual.ListUsersById Test.Wire.API.Golden.Manual.Login_user @@ -633,6 +635,7 @@ test-suite wire-api-golden-tests , lens , pem , proto-lens + , saml2-web-sso , string-conversions , tasty , tasty-hunit diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix index 768fcdc5cac..3653e611057 100644 --- a/libs/wire-subsystems/default.nix +++ b/libs/wire-subsystems/default.nix @@ -7,6 +7,7 @@ , amazonka , amazonka-core , amazonka-ses +, amqp , async , attoparsec , base @@ -96,6 +97,7 @@ mkDerivation { amazonka amazonka-core amazonka-ses + amqp async attoparsec base diff --git a/libs/wire-subsystems/src/Wire/ActivationCodeStore.hs b/libs/wire-subsystems/src/Wire/ActivationCodeStore.hs index 9473bd16f58..00cdcf0bad6 100644 --- a/libs/wire-subsystems/src/Wire/ActivationCodeStore.hs +++ b/libs/wire-subsystems/src/Wire/ActivationCodeStore.hs @@ -21,10 +21,21 @@ module Wire.ActivationCodeStore where import Data.Id import Imports import Polysemy +import Util.Timeout import Wire.API.User.Activation import Wire.UserKeyStore data ActivationCodeStore :: Effect where - LookupActivationCode :: EmailKey -> ActivationCodeStore m (Maybe (Maybe UserId, ActivationCode)) + LookupActivationCode :: + EmailKey -> + ActivationCodeStore m (Maybe (Maybe UserId, ActivationCode)) + -- | Create a code for a new pending activation for a given 'EmailKey' + NewActivationCode :: + EmailKey -> + -- | The timeout for the activation code. + Timeout -> + -- | The user with whom to associate the activation code. + Maybe UserId -> + ActivationCodeStore m Activation makeSem ''ActivationCodeStore diff --git a/libs/wire-subsystems/src/Wire/ActivationCodeStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ActivationCodeStore/Cassandra.hs index 7f0ba27ba03..01349e6102b 100644 --- a/libs/wire-subsystems/src/Wire/ActivationCodeStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/ActivationCodeStore/Cassandra.hs @@ -1,31 +1,63 @@ -module Wire.ActivationCodeStore.Cassandra where +module Wire.ActivationCodeStore.Cassandra (interpretActivationCodeStoreToCassandra) where import Cassandra import Data.Id +import Data.Text (pack) import Data.Text.Ascii qualified as Ascii import Data.Text.Encoding qualified as T import Imports +import OpenSSL.BN (randIntegerZeroToNMinusOne) import OpenSSL.EVP.Digest import Polysemy import Polysemy.Embed +import Text.Printf (printf) +import Util.Timeout import Wire.API.User.Activation +import Wire.API.User.EmailAddress import Wire.ActivationCodeStore -import Wire.UserKeyStore (EmailKey, emailKeyUniq) +import Wire.UserKeyStore interpretActivationCodeStoreToCassandra :: (Member (Embed IO) r) => ClientState -> InterpreterFor ActivationCodeStore r interpretActivationCodeStoreToCassandra casClient = interpret $ - runEmbedded (runClient casClient) . \case - LookupActivationCode ek -> embed do + runEmbedded (runClient casClient) . embed . \case + LookupActivationCode ek -> do liftIO (mkActivationKey ek) >>= retry x1 . query1 cql . params LocalQuorum . Identity + NewActivationCode ek timeout uid -> newActivationCodeImpl ek timeout uid where cql :: PrepQuery R (Identity ActivationKey) (Maybe UserId, ActivationCode) cql = - [sql| + [sql| SELECT user, code FROM activation_keys WHERE key = ? |] +-- | Create a new pending activation for a given 'EmailKey'. +newActivationCodeImpl :: + (MonadClient m) => + EmailKey -> + -- | The timeout for the activation code. + Timeout -> + -- | The user with whom to associate the activation code. + Maybe UserId -> + m Activation +newActivationCodeImpl uk timeout u = do + let typ = "email" + key = fromEmail (emailKeyOrig uk) + code <- liftIO $ genCode + insert typ key code + where + insert t k c = do + key <- liftIO $ mkActivationKey uk + retry x5 . write keyInsert $ params LocalQuorum (key, t, k, c, u, maxAttempts, round timeout) + pure $ Activation key c + genCode = + ActivationCode . Ascii.unsafeFromText . pack . printf "%06d" + <$> randIntegerZeroToNMinusOne 1000000 + +-------------------------------------------------------------------------------- +-- Utilities + mkActivationKey :: EmailKey -> IO ActivationKey mkActivationKey k = do Just d <- getDigestByName "SHA256" @@ -35,3 +67,13 @@ mkActivationKey k = do . digestBS d . T.encodeUtf8 $ emailKeyUniq k + +keyInsert :: PrepQuery W (ActivationKey, Text, Text, ActivationCode, Maybe UserId, Int32, Int32) () +keyInsert = + "INSERT INTO activation_keys \ + \(key, key_type, key_text, code, user, retries) VALUES \ + \(? , ? , ? , ? , ? , ? ) USING TTL ?" + +-- | Max. number of activation attempts per 'ActivationKey'. +maxAttempts :: Int32 +maxAttempts = 3 diff --git a/libs/wire-subsystems/src/Wire/EmailSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/EmailSubsystem/Interpreter.hs index ef03dadc7db..c07ca6ee8c0 100644 --- a/libs/wire-subsystems/src/Wire/EmailSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/EmailSubsystem/Interpreter.hs @@ -3,6 +3,7 @@ module Wire.EmailSubsystem.Interpreter ( emailSubsystemInterpreter, mkMimeAddress, + renderInvitationUrl, ) where diff --git a/libs/wire-subsystems/src/Wire/EmailSubsystem/Template.hs b/libs/wire-subsystems/src/Wire/EmailSubsystem/Template.hs index ca79185fccc..7c209b2972a 100644 --- a/libs/wire-subsystems/src/Wire/EmailSubsystem/Template.hs +++ b/libs/wire-subsystems/src/Wire/EmailSubsystem/Template.hs @@ -219,13 +219,13 @@ data PersonalUserMemberWelcomeEmailTemplate = PersonalUserMemberWelcomeEmailTemp personalUserMemberWelcomeEmailSenderName :: !Text } -data PersonalUserCreatorWelcomeEmailTemplate = PersonalUserCreatorWelcomeEmailTemplate - { personalUserCreatorWelcomeEmailUrl :: !Text, - personalUserCreatorWelcomeEmailSubject :: !Template, - personalUserCreatorWelcomeEmailBodyText :: !Template, - personalUserCreatorWelcomeEmailBodyHtml :: !Template, - personalUserCreatorWelcomeEmailSender :: !EmailAddress, - personalUserCreatorWelcomeEmailSenderName :: !Text +data NewTeamOwnerWelcomeEmailTemplate = NewTeamOwnerWelcomeEmailTemplate + { newTeamOwnerWelcomeEmailUrl :: !Text, + newTeamOwnerWelcomeEmailSubject :: !Template, + newTeamOwnerWelcomeEmailBodyText :: !Template, + newTeamOwnerWelcomeEmailBodyHtml :: !Template, + newTeamOwnerWelcomeEmailSender :: !EmailAddress, + newTeamOwnerWelcomeEmailSenderName :: !Text } data TeamTemplates = TeamTemplates @@ -234,5 +234,5 @@ data TeamTemplates = TeamTemplates creatorWelcomeEmail :: !CreatorWelcomeEmailTemplate, memberWelcomeEmail :: !MemberWelcomeEmailTemplate, personalUserMemberWelcomeEmail :: !PersonalUserMemberWelcomeEmailTemplate, - personalUserCreatorWelcomeEmail :: !PersonalUserCreatorWelcomeEmailTemplate + newTeamOwnerWelcomeEmail :: !NewTeamOwnerWelcomeEmailTemplate } diff --git a/libs/wire-subsystems/src/Wire/GundeckAPIAccess.hs b/libs/wire-subsystems/src/Wire/GundeckAPIAccess.hs index c153cf22364..e402416a21b 100644 --- a/libs/wire-subsystems/src/Wire/GundeckAPIAccess.hs +++ b/libs/wire-subsystems/src/Wire/GundeckAPIAccess.hs @@ -17,6 +17,9 @@ data GundeckAPIAccess m a where UserDeleted :: UserId -> GundeckAPIAccess m () UnregisterPushClient :: UserId -> ClientId -> GundeckAPIAccess m () GetPushTokens :: UserId -> GundeckAPIAccess m [V2.PushToken] + RegisterConsumableNotifcationsClient :: UserId -> ClientId -> GundeckAPIAccess m () + +deriving instance Show (GundeckAPIAccess m a) makeSem ''GundeckAPIAccess @@ -50,3 +53,8 @@ runGundeckAPIAccess ep = interpret $ \case . zUser uid . expect2xx responseJsonMaybe rsp & maybe (pure []) (pure . V2.pushTokens) + RegisterConsumableNotifcationsClient uid cid -> do + void . rpcWithRetries "gundeck" ep $ + method POST + . paths ["i", "users", toByteString' uid, "clients", toByteString' cid, "consumable-notifications"] + . expect2xx diff --git a/libs/wire-subsystems/src/Wire/InvitationStore.hs b/libs/wire-subsystems/src/Wire/InvitationStore.hs index e691f516bf7..04a35c3ce36 100644 --- a/libs/wire-subsystems/src/Wire/InvitationStore.hs +++ b/libs/wire-subsystems/src/Wire/InvitationStore.hs @@ -40,6 +40,7 @@ data StoredInvitation = MkStoredInvitation invitationId :: InvitationId, createdAt :: UTCTimeMillis, createdBy :: Maybe UserId, + -- | The invitee's email address email :: EmailAddress, name :: Maybe Name, code :: InvitationCode diff --git a/libs/wire-subsystems/src/Wire/NotificationSubsystem.hs b/libs/wire-subsystems/src/Wire/NotificationSubsystem.hs index d854c0acb1b..9274cce698d 100644 --- a/libs/wire-subsystems/src/Wire/NotificationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/NotificationSubsystem.hs @@ -49,6 +49,7 @@ data NotificationSubsystem m a where CleanupUser :: UserId -> NotificationSubsystem m () UnregisterPushClient :: UserId -> ClientId -> NotificationSubsystem m () GetPushTokens :: UserId -> NotificationSubsystem m [PushToken] + SetupConsumableNotifications :: UserId -> ClientId -> NotificationSubsystem m () makeSem ''NotificationSubsystem diff --git a/libs/wire-subsystems/src/Wire/NotificationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/NotificationSubsystem/Interpreter.hs index 5b2859d1ff1..89d80fcb70c 100644 --- a/libs/wire-subsystems/src/Wire/NotificationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/NotificationSubsystem/Interpreter.hs @@ -46,6 +46,7 @@ runNotificationSubsystemGundeck cfg = interpret $ \case CleanupUser uid -> GundeckAPIAccess.userDeleted uid UnregisterPushClient uid cid -> GundeckAPIAccess.unregisterPushClient uid cid GetPushTokens uid -> GundeckAPIAccess.getPushTokens uid + SetupConsumableNotifications uid cid -> GundeckAPIAccess.registerConsumableNotifcationsClient uid cid data NotificationSubsystemConfig = NotificationSubsystemConfig { fanoutLimit :: Range 1 HardTruncationLimit Int32, diff --git a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs index 46445645c9d..6916ffcb08e 100644 --- a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs @@ -131,10 +131,12 @@ createInvitation' tid mExpectedInvId inviteeRole mbInviterUid inviterEmail invRe mEmailOwner <- getLocalUserAccountByUserKey uke isPersonalUserMigration <- case mEmailOwner of Nothing -> pure False - Just user -> - if (user.userStatus == Active && isNothing user.userTeam) - then pure True - else throw TeamInvitationEmailTaken + Just user + | invRequest.allowExisting + && user.userStatus == Active + && isNothing user.userTeam -> + pure True + | otherwise -> throw TeamInvitationEmailTaken maxSize <- maxTeamSize <$> input pending <- Store.countInvitations tid @@ -159,7 +161,6 @@ createInvitation' tid mExpectedInvId inviteeRole mbInviterUid inviterEmail invRe inviteeEmail = email, inviteeName = invRequest.inviteeName, code = code - -- mUrl = mUrl } in Store.insertInvitation insertInv timeout diff --git a/libs/wire-subsystems/src/Wire/UserStore.hs b/libs/wire-subsystems/src/Wire/UserStore.hs index a5189d29818..6f24e084717 100644 --- a/libs/wire-subsystems/src/Wire/UserStore.hs +++ b/libs/wire-subsystems/src/Wire/UserStore.hs @@ -54,6 +54,7 @@ data UserStore m a where GetIndexUsersPaginated :: Int32 -> Maybe PagingState -> UserStore m (PageWithState IndexUser) GetUsers :: [UserId] -> UserStore m [StoredUser] UpdateUser :: UserId -> StoredUserUpdate -> UserStore m () + UpdateEmailUnvalidated :: UserId -> EmailAddress -> UserStore m () UpdateUserHandleEither :: UserId -> StoredUserHandleUpdate -> UserStore m (Either StoredUserUpdateError ()) DeleteUser :: User -> UserStore m () -- | This operation looks up a handle but is guaranteed to not give you stale locks. @@ -73,6 +74,7 @@ data UserStore m a where GetActivityTimestamps :: UserId -> UserStore m [Maybe UTCTime] GetRichInfo :: UserId -> UserStore m (Maybe RichInfoAssocList) GetUserAuthenticationInfo :: UserId -> UserStore m (Maybe (Maybe Password, AccountStatus)) + DeleteEmail :: UserId -> UserStore m () makeSem ''UserStore diff --git a/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs index 96e78df99d3..d113e202496 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs @@ -26,6 +26,7 @@ interpretUserStoreCassandra casClient = GetIndexUser uid -> getIndexUserImpl uid GetIndexUsersPaginated pageSize mPagingState -> getIndexUserPaginatedImpl pageSize mPagingState UpdateUser uid update -> updateUserImpl uid update + UpdateEmailUnvalidated uid email -> updateEmailUnvalidatedImpl uid email UpdateUserHandleEither uid update -> updateUserHandleEitherImpl uid update DeleteUser user -> deleteUserImpl user LookupHandle hdl -> lookupHandleImpl LocalQuorum hdl @@ -37,6 +38,7 @@ interpretUserStoreCassandra casClient = GetActivityTimestamps uid -> getActivityTimestampsImpl uid GetRichInfo uid -> getRichInfoImpl uid GetUserAuthenticationInfo uid -> getUserAuthenticationInfoImpl uid + DeleteEmail uid -> deleteEmailImpl uid getUserAuthenticationInfoImpl :: UserId -> Client (Maybe (Maybe Password, AccountStatus)) getUserAuthenticationInfoImpl uid = fmap f <$> retry x1 (query1 authSelect (params LocalQuorum (Identity uid))) @@ -105,6 +107,13 @@ updateUserImpl uid update = for_ update.accentId \c -> addPrepQuery userAccentIdUpdate (c, uid) for_ update.supportedProtocols \a -> addPrepQuery userSupportedProtocolsUpdate (a, uid) +updateEmailUnvalidatedImpl :: UserId -> EmailAddress -> Client () +updateEmailUnvalidatedImpl u e = + retry x5 $ write userEmailUnvalidatedUpdate (params LocalQuorum (e, u)) + where + userEmailUnvalidatedUpdate :: PrepQuery W (EmailAddress, UserId) () + userEmailUnvalidatedUpdate = "UPDATE user SET email_unvalidated = ? WHERE id = ?" + updateUserHandleEitherImpl :: UserId -> StoredUserHandleUpdate -> Client (Either StoredUserUpdateError ()) updateUserHandleEitherImpl uid update = runM $ runError do @@ -200,6 +209,9 @@ getRichInfoImpl uid = q :: PrepQuery R (Identity UserId) (Identity RichInfoAssocList) q = "SELECT json FROM rich_info WHERE user = ?" +deleteEmailImpl :: UserId -> Client () +deleteEmailImpl u = retry x5 $ write userEmailDelete (params LocalQuorum (Identity u)) + -------------------------------------------------------------------------------- -- Queries @@ -259,3 +271,6 @@ activatedSelect = "SELECT activated FROM user WHERE id = ?" localeSelect :: PrepQuery R (Identity UserId) (Maybe Language, Maybe Country) localeSelect = "SELECT language, country FROM user WHERE id = ?" + +userEmailDelete :: PrepQuery W (Identity UserId) () +userEmailDelete = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET email = null, write_time_bumper = 0 WHERE id = ?" diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem.hs b/libs/wire-subsystems/src/Wire/UserSubsystem.hs index f53da756a00..aa36224b864 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem.hs @@ -18,20 +18,28 @@ import Data.Range import Imports import Polysemy import Polysemy.Error +import Polysemy.Input import Wire.API.Federation.Error import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti (TeamStatus) import Wire.API.Team.Export (TeamExportUser) 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.Search +import Wire.ActivationCodeStore import Wire.Arbitrary +import Wire.BlockListStore +import Wire.BlockListStore qualified as BlockListStore +import Wire.EmailSubsystem import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.GalleyAPIAccess qualified as GalleyAPIAccess import Wire.InvitationStore -import Wire.UserKeyStore (EmailKey, emailKeyOrig) +import Wire.UserKeyStore import Wire.UserSearch.Types +import Wire.UserStore import Wire.UserSubsystem.Error (UserSubsystemError (..)) +import Wire.UserSubsystem.UserSubsystemConfig -- | Who is performing this update operation / who is allowed to? (Single source of truth: -- users managed by SCIM can't be updated by clients and vice versa.) @@ -88,6 +96,14 @@ data GetBy = MkGetBy instance Default GetBy where def = MkGetBy NoPendingInvitations [] [] +-- | Outcome of email change invariant checks. +data ChangeEmailResult + = -- | The request was successful, user needs to verify the new email address + ChangeEmailNeedsActivation !(User, Activation, EmailAddress) + | -- | The user asked to change the email address to the one already owned + ChangeEmailIdempotent + deriving (Show) + data UserSubsystem m a where -- | First arg is for authorization only. GetUserProfiles :: Local UserId -> [Qualified UserId] -> UserSubsystem m [UserProfile] @@ -145,6 +161,7 @@ data UserSubsystem m a where InternalUpdateSearchIndex :: UserId -> UserSubsystem m () InternalFindTeamInvitation :: Maybe EmailKey -> InvitationCode -> UserSubsystem m StoredInvitation GetUserExportData :: UserId -> UserSubsystem m (Maybe TeamExportUser) + RemoveEmailEither :: Local UserId -> UserSubsystem m (Either UserSubsystemError ()) -- | the return type of 'CheckHandle' data CheckHandleResp @@ -177,10 +194,82 @@ getLocalAccountBy includePendingInvitations uid = } ) +getUserEmail :: (Member UserSubsystem r) => Local UserId -> Sem r (Maybe EmailAddress) +getUserEmail lusr = + (>>= userEmail) <$> getLocalAccountBy WithPendingInvitations lusr + getLocalUserAccountByUserKey :: (Member UserSubsystem r) => Local EmailKey -> Sem r (Maybe User) getLocalUserAccountByUserKey q@(tUnqualified -> ek) = listToMaybe <$> getAccountsByEmailNoFilter (qualifyAs q [emailKeyOrig ek]) +-- | Call 'createEmailChangeToken' and process result: if email changes to +-- itself, succeed, if not, send validation email. +requestEmailChange :: + ( Member BlockListStore r, + Member UserKeyStore r, + Member EmailSubsystem r, + Member UserSubsystem r, + Member UserStore r, + Member (Error UserSubsystemError) r, + Member ActivationCodeStore r, + Member (Input UserSubsystemConfig) r + ) => + Local UserId -> + EmailAddress -> + UpdateOriginType -> + Sem r ChangeEmailResponse +requestEmailChange lusr email allowScim = do + let u = tUnqualified lusr + createEmailChangeToken lusr email allowScim >>= \case + ChangeEmailIdempotent -> + pure ChangeEmailResponseIdempotent + ChangeEmailNeedsActivation (usr, adata, en) -> do + sendOutEmail usr adata en + updateEmailUnvalidated u email + internalUpdateSearchIndex u + pure ChangeEmailResponseNeedsActivation + where + sendOutEmail usr adata en = do + (maybe sendActivationMail (const sendEmailAddressUpdateMail) usr.userIdentity) + en + (userDisplayName usr) + (activationKey adata) + (activationCode adata) + (Just (userLocale usr)) + +-- | Prepare changing the email (checking a number of invariants). +createEmailChangeToken :: + ( Member BlockListStore r, + Member UserKeyStore r, + Member (Error UserSubsystemError) r, + Member UserSubsystem r, + Member ActivationCodeStore r, + Member (Input UserSubsystemConfig) r + ) => + Local UserId -> + EmailAddress -> + UpdateOriginType -> + Sem r ChangeEmailResult +createEmailChangeToken lusr email updateOrigin = do + let ek = mkEmailKey email + u = tUnqualified lusr + blocklisted <- BlockListStore.exists ek + when blocklisted $ throw UserSubsystemChangeBlocklistedEmail + available <- keyAvailable ek (Just u) + unless available $ throw UserSubsystemEmailExists + usr <- + getLocalAccountBy WithPendingInvitations lusr + >>= note UserSubsystemProfileNotFound + case emailIdentity =<< userIdentity usr of + -- The user already has an email address and the new one is exactly the same + Just current | current == email -> pure ChangeEmailIdempotent + _ -> do + unless (userManagedBy usr /= ManagedByScim || updateOrigin == UpdateOriginScim) $ + throw UserSubsystemEmailManagedByScim + actTimeout <- inputs (.activationCodeTimeout) + act <- newActivationCode ek actTimeout (Just u) + pure $ ChangeEmailNeedsActivation (usr, act, email) + ------------------------------------------ -- FUTUREWORK: Pending functions for a team subsystem ------------------------------------------ diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Error.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Error.hs index 90a2d39a888..f9005275289 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Error.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Error.hs @@ -14,7 +14,9 @@ data UserSubsystemError UserSubsystemDisplayNameManagedByScim | UserSubsystemHandleManagedByScim | UserSubsystemLocaleManagedByScim + | UserSubsystemEmailManagedByScim | UserSubsystemNoIdentity + | UserSubsystemLastIdentity | UserSubsystemHandleExists | UserSubsystemInvalidHandle | UserSubsystemProfileNotFound @@ -28,6 +30,8 @@ data UserSubsystemError | UserSubsystemInvitationNotFound | UserSubsystemUserNotAllowedToJoinTeam Wai.Error | UserSubsystemMLSServicesNotAllowed + | UserSubsystemChangeBlocklistedEmail + | UserSubsystemEmailExists deriving (Eq, Show) userSubsystemErrorToHttpError :: UserSubsystemError -> HttpError @@ -36,7 +40,9 @@ userSubsystemErrorToHttpError = UserSubsystemProfileNotFound -> errorToWai @E.UserNotFound UserSubsystemDisplayNameManagedByScim -> errorToWai @E.NameManagedByScim UserSubsystemLocaleManagedByScim -> errorToWai @E.LocaleManagedByScim + UserSubsystemEmailManagedByScim -> errorToWai @E.EmailManagedByScim UserSubsystemNoIdentity -> errorToWai @E.NoIdentity + UserSubsystemLastIdentity -> errorToWai @E.LastIdentity UserSubsystemHandleExists -> errorToWai @E.HandleExists UserSubsystemInvalidHandle -> errorToWai @E.InvalidHandle UserSubsystemHandleManagedByScim -> errorToWai @E.HandleManagedByScim @@ -50,5 +56,7 @@ userSubsystemErrorToHttpError = UserSubsystemInvitationNotFound -> Wai.mkError status404 "not-found" "Something went wrong, while looking up the invitation" UserSubsystemUserNotAllowedToJoinTeam e -> e UserSubsystemMLSServicesNotAllowed -> errorToWai @E.MLSServicesNotAllowed + UserSubsystemChangeBlocklistedEmail -> errorToWai @E.BlacklistedEmail + UserSubsystemEmailExists -> errorToWai @'E.UserKeyExists instance Exception UserSubsystemError diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index 8f9ba2566e1..5424bbe4f5b 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -48,7 +48,6 @@ import Wire.API.User as User import Wire.API.User.RichInfo import Wire.API.User.Search import Wire.API.UserEvent -import Wire.Arbitrary import Wire.AuthenticationSubsystem import Wire.BlockListStore as BlockList import Wire.DeleteQueue @@ -75,17 +74,9 @@ import Wire.UserStore.IndexUser import Wire.UserSubsystem import Wire.UserSubsystem.Error import Wire.UserSubsystem.HandleBlacklist +import Wire.UserSubsystem.UserSubsystemConfig import Witherable (wither) -data UserSubsystemConfig = UserSubsystemConfig - { emailVisibilityConfig :: EmailVisibilityConfig, - defaultLocale :: Locale, - searchSameTeamOnly :: Bool, - maxTeamSize :: Word32 - } - deriving (Show, Generic) - deriving (Arbitrary) via (GenericUniform UserSubsystemConfig) - runUserSubsystem :: ( Member UserStore r, Member UserKeyStore r, @@ -157,6 +148,7 @@ runUserSubsystem authInterpreter = interpret $ InternalFindTeamInvitation mEmailKey code -> internalFindTeamInvitationImpl mEmailKey code GetUserExportData uid -> getUserExportDataImpl uid + RemoveEmailEither luid -> removeEmailEitherImpl luid scimExtId :: StoredUser -> Maybe Text scimExtId su = do @@ -974,3 +966,26 @@ getUserExportDataImpl uid = fmap hush . runError @() $ do tExportLastActive = lastActive, tExportStatus = su.status } + +removeEmailEitherImpl :: + ( Member UserKeyStore r, + Member UserStore r, + Member Events r, + Member IndexedUserStore r, + Member (Input UserSubsystemConfig) r, + Member GalleyAPIAccess r, + Member Metrics r + ) => + Local UserId -> + Sem r (Either UserSubsystemError ()) +removeEmailEitherImpl lusr = runError $ do + let uid = tUnqualified lusr + ident <- getSelfProfileImpl lusr >>= note UserSubsystemProfileNotFound + case ident.selfUser.userIdentity of + Just (SSOIdentity (UserSSOId _) (Just e)) -> do + deleteKey $ mkEmailKey e + deleteEmail uid + generateUserEvent uid Nothing (emailRemoved uid e) + syncUserIndex uid + Just _ -> throw UserSubsystemLastIdentity + Nothing -> throw UserSubsystemNoIdentity diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/UserSubsystemConfig.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/UserSubsystemConfig.hs new file mode 100644 index 00000000000..094e541a8b1 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/UserSubsystemConfig.hs @@ -0,0 +1,16 @@ +module Wire.UserSubsystem.UserSubsystemConfig where + +import Imports +import Util.Timeout +import Wire.API.User +import Wire.Arbitrary + +data UserSubsystemConfig = UserSubsystemConfig + { emailVisibilityConfig :: EmailVisibilityConfig, + defaultLocale :: Locale, + searchSameTeamOnly :: Bool, + maxTeamSize :: Word32, + activationCodeTimeout :: Timeout + } + deriving (Show, Generic) + deriving (Arbitrary) via (GenericUniform UserSubsystemConfig) diff --git a/libs/wire-subsystems/test/unit/Wire/ActivationCodeStore/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ActivationCodeStore/InterpreterSpec.hs new file mode 100644 index 00000000000..c8790485fd8 --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/ActivationCodeStore/InterpreterSpec.hs @@ -0,0 +1,40 @@ +module Wire.ActivationCodeStore.InterpreterSpec (spec) where + +import Data.Default +import Data.Map qualified as Map +import Imports +import Test.Hspec +import Test.Hspec.QuickCheck +import Test.QuickCheck +import Wire.API.User.Activation +import Wire.ActivationCodeStore +import Wire.MiniBackend +import Wire.MockInterpreters.ActivationCodeStore + +spec :: Spec +spec = do + describe "ActivationCodeStore effect" $ do + prop "a code can be looked up" $ \emailKey config -> + let c = code emailKey + localBackend = + def {activationCodes = Map.singleton emailKey (Nothing, c)} + result = + runNoFederationStack localBackend Nothing config $ + lookupActivationCode emailKey + in result === Just (Nothing, c) + prop "a code not found in the store" $ \emailKey config -> + let localBackend = def + result = + runNoFederationStack localBackend Nothing config $ + lookupActivationCode emailKey + in result === Nothing + prop "newly added code can be looked up" $ \emailKey mUid config -> + let c = code emailKey + localBackend = def + (actCode, lookupRes) = + runNoFederationStack localBackend Nothing config $ do + ac <- + (.activationCode) + <$> newActivationCode emailKey undefined mUid + (ac,) <$> lookupActivationCode emailKey + in actCode === c .&&. lookupRes === Just (mUid, c) diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index ee951963bbc..2dbf1a3a9bc 100644 --- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs +++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs @@ -21,6 +21,7 @@ module Wire.MiniBackend NotPendingStoredUser (..), NotPendingEmptyIdentityStoredUser (..), PendingNotEmptyIdentityStoredUser (..), + NotPendingSSOIdWithEmailStoredUser (..), PendingStoredUser (..), ) where @@ -126,6 +127,23 @@ instance Arbitrary NotPendingStoredUser where notPendingStatus <- elements (Nothing : map Just [Active, Suspended, Ephemeral]) pure $ NotPendingStoredUser (user {status = notPendingStatus}) +newtype NotPendingSSOIdWithEmailStoredUser = NotPendingSSOIdWithEmailStoredUser StoredUser + deriving (Show, Eq) + +instance Arbitrary NotPendingSSOIdWithEmailStoredUser where + arbitrary = do + user <- arbitrary `suchThat` \user -> fmap isUserSSOId user.ssoId == Just True + notPendingStatus <- elements (Nothing : map Just [Active, Suspended, Ephemeral]) + e <- arbitrary + pure $ + NotPendingSSOIdWithEmailStoredUser + ( user + { activated = True, + status = notPendingStatus, + email = Just e + } + ) + type AllErrors = [ Error UserSubsystemError, Error FederationError, diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ActivationCodeStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ActivationCodeStore.hs index 0265c8d07fe..c7b85c9b81b 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ActivationCodeStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ActivationCodeStore.hs @@ -2,12 +2,38 @@ module Wire.MockInterpreters.ActivationCodeStore where import Data.Id import Data.Map +import Data.Text (pack) +import Data.Text.Ascii qualified as Ascii +import Data.Text.Encoding qualified as T import Imports import Polysemy import Polysemy.State +import Text.Printf (printf) import Wire.API.User.Activation import Wire.ActivationCodeStore (ActivationCodeStore (..)) import Wire.UserKeyStore -inMemoryActivationCodeStoreInterpreter :: (Member (State (Map EmailKey (Maybe UserId, ActivationCode))) r) => InterpreterFor ActivationCodeStore r -inMemoryActivationCodeStoreInterpreter = interpret \case LookupActivationCode ek -> gets (!? ek) +code :: EmailKey -> ActivationCode +code = + ActivationCode + . Ascii.unsafeFromText + . pack + . printf "%06d" + . length + . show + +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 = code ek + modify (insert ek (uid, c)) $> Activation key c diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs index 133365cf986..c782d073e2e 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs @@ -37,6 +37,13 @@ inMemoryUserStoreInterpreter = interpret $ \case . maybe Imports.id setStoredUserSupportedProtocols update.supportedProtocols $ u else u + UpdateEmailUnvalidated uid email -> modify (map doUpdate) + where + doUpdate :: StoredUser -> StoredUser + doUpdate u = + if u.id == uid + then u {emailUnvalidated = Just email} + else u GetIndexUser uid -> gets $ fmap storedUserToIndexUser . find (\user -> user.id == uid) GetIndexUsersPaginated _pageSize _pagingState -> @@ -74,6 +81,10 @@ inMemoryUserStoreInterpreter = interpret $ \case GetActivityTimestamps _ -> pure [] GetRichInfo _ -> error "rich info not implemented" GetUserAuthenticationInfo _uid -> error "Not implemented" + DeleteEmail uid -> modify (map doUpdate) + where + doUpdate :: StoredUser -> StoredUser + doUpdate u = if u.id == uid then u {email = Nothing} else u storedUserToIndexUser :: StoredUser -> IndexUser storedUserToIndexUser storedUser = diff --git a/libs/wire-subsystems/test/unit/Wire/NotificationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/NotificationSubsystem/InterpreterSpec.hs index 9fbd3babe89..aae5e6710cc 100644 --- a/libs/wire-subsystems/test/unit/Wire/NotificationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/NotificationSubsystem/InterpreterSpec.hs @@ -307,37 +307,40 @@ runMiniStackWithControlledDelay mockConfig delayControl actualPushesRef = do . runControlledDelay delayControl . runInputConst mockConfig -runGundeckAPIAccessFailure :: (Member (Embed IO) r) => IORef [[V2.Push]] -> Sem (GundeckAPIAccess : r) a -> Sem r a +runGundeckAPIAccessFailure :: forall r a. (Member (Embed IO) r) => IORef [[V2.Push]] -> Sem (GundeckAPIAccess : r) a -> Sem r a runGundeckAPIAccessFailure pushesRef = interpret $ \action -> do + let unexpectedCall :: forall x. Sem r x + unexpectedCall = do + liftIO $ expectationFailure $ "Unexpected call to GundeckAPI: " <> show action + error "impossible" case action of PushV2 pushes -> liftIO $ do modifyIORef pushesRef (<> [pushes]) throwIO TestException - GundeckAPIAccess.UserDeleted uid -> - liftIO $ expectationFailure $ "Unexpected call to GundeckAPI: UserDeleted " <> show uid - GundeckAPIAccess.UnregisterPushClient uid cid -> - liftIO $ expectationFailure $ "Unexpected call to GundeckAPI: UnregisterPushClient " <> show uid <> " " <> show cid - GundeckAPIAccess.GetPushTokens uid -> do - liftIO $ expectationFailure $ "Unexpected call to GundeckAPI: GetPushTokens " <> show uid - error "impossible" + GundeckAPIAccess.UserDeleted {} -> unexpectedCall + GundeckAPIAccess.UnregisterPushClient {} -> unexpectedCall + GundeckAPIAccess.GetPushTokens {} -> unexpectedCall + GundeckAPIAccess.RegisterConsumableNotifcationsClient {} -> unexpectedCall data TestException = TestException deriving (Show) instance Exception TestException -runGundeckAPIAccessIORef :: (Member (Embed IO) r) => IORef [[V2.Push]] -> Sem (GundeckAPIAccess : r) a -> Sem r a +runGundeckAPIAccessIORef :: forall r a. (Member (Embed IO) r) => IORef [[V2.Push]] -> Sem (GundeckAPIAccess : r) a -> Sem r a runGundeckAPIAccessIORef pushesRef = - interpret \case - PushV2 pushes -> modifyIORef pushesRef (<> [pushes]) - GundeckAPIAccess.UserDeleted uid -> - liftIO $ expectationFailure $ "Unexpected call to GundeckAPI: UserDeleted " <> show uid - GundeckAPIAccess.UnregisterPushClient uid cid -> - liftIO $ expectationFailure $ "Unexpected call to GundeckAPI: UnregisterPushClient " <> show uid <> " " <> show cid - GundeckAPIAccess.GetPushTokens uid -> do - liftIO $ expectationFailure $ "Unexpected call to GundeckAPI: GetPushTokens " <> show uid - error "impossible" + interpret \action -> do + let unexpectedCall :: forall x. Sem r x + unexpectedCall = do + liftIO $ expectationFailure $ "Unexpected call to GundeckAPI " <> show action + error "impossible" + case action of + PushV2 pushes -> modifyIORef pushesRef (<> [pushes]) + GundeckAPIAccess.UserDeleted {} -> unexpectedCall + GundeckAPIAccess.UnregisterPushClient {} -> unexpectedCall + GundeckAPIAccess.GetPushTokens {} -> unexpectedCall + GundeckAPIAccess.RegisterConsumableNotifcationsClient {} -> unexpectedCall waitUntilPushes :: IORef [a] -> Int -> IO [a] waitUntilPushes pushesRef n = do diff --git a/libs/wire-subsystems/test/unit/Wire/UserStoreSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserStoreSpec.hs index 5f9192c469b..6d3df61cecc 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserStoreSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserStoreSpec.hs @@ -1,11 +1,15 @@ module Wire.UserStoreSpec (spec) where +import Data.Default import Imports +import Polysemy.State import Test.Hspec import Test.Hspec.QuickCheck import Test.QuickCheck import Wire.API.User +import Wire.MiniBackend import Wire.StoredUser +import Wire.UserStore spec :: Spec spec = do @@ -33,3 +37,21 @@ spec = do in if (isJust storedUser.language) then user.userLocale === Locale (fromJust storedUser.language) storedUser.country else user.userLocale === defaultLocale + + describe "UserStore effect" $ do + prop "user self email deleted" $ \user1 user2' email2 config -> + let user2 = user2' {email = Just email2} + localBackend = def {users = [user1, user2]} + result = + runNoFederationStack localBackend Nothing config $ do + deleteEmail (user1.id) + gets users + in result === [user1 {email = Nothing}, user2] + prop "update unvalidated email" $ \user1 user2 email1 config -> + let updatedUser1 = user1 {emailUnvalidated = Just email1} + localBackend = def {users = [user1, user2]} + result = + runNoFederationStack localBackend Nothing config $ do + updateEmailUnvalidated (user1.id) email1 + gets users + in result === [updatedUser1, user2] diff --git a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs index c573d4709c5..ed361ba70ed 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs @@ -57,7 +57,7 @@ spec = describe "UserSubsystem.Interpreter" do target1 = mkUserIds remoteDomain1 targetUsers1 target2 = mkUserIds remoteDomain2 targetUsers2 localBackend = def {users = [viewer] <> localTargetUsers} - config = UserSubsystemConfig visibility miniLocale False 100 + config = UserSubsystemConfig visibility miniLocale False 100 undefined retrievedProfiles = runFederationStack localBackend federation Nothing config $ getUserProfiles @@ -85,7 +85,7 @@ spec = describe "UserSubsystem.Interpreter" do mkUserIds domain users = map (flip Qualified domain . (.id)) users onlineUsers = mkUserIds onlineDomain onlineTargetUsers offlineUsers = mkUserIds offlineDomain offlineTargetUsers - config = UserSubsystemConfig visibility miniLocale False 100 + config = UserSubsystemConfig visibility miniLocale False 100 undefined localBackend = def {users = [viewer]} result = run @@ -155,7 +155,7 @@ spec = describe "UserSubsystem.Interpreter" do \viewer targetUsers visibility domain remoteDomain -> do let remoteBackend = def {users = targetUsers} federation = [(remoteDomain, remoteBackend)] - config = UserSubsystemConfig visibility miniLocale False 100 + config = UserSubsystemConfig visibility miniLocale False 100 undefined localBackend = def {users = [viewer]} retrievedProfilesWithErrors :: ([(Qualified UserId, FederationError)], [UserProfile]) = runFederationStack localBackend federation Nothing config $ @@ -176,7 +176,7 @@ spec = describe "UserSubsystem.Interpreter" do prop "Remote users on offline backend always fail to return" $ \viewer (targetUsers :: Set StoredUser) visibility domain remoteDomain -> do let online = mempty - config = UserSubsystemConfig visibility miniLocale False 100 + config = UserSubsystemConfig visibility miniLocale False 100 undefined localBackend = def {users = [viewer]} retrievedProfilesWithErrors :: ([(Qualified UserId, FederationError)], [UserProfile]) = runFederationStack localBackend online Nothing config $ @@ -196,7 +196,7 @@ spec = describe "UserSubsystem.Interpreter" do allDomains = [domain, remoteDomainA, remoteDomainB] remoteAUsers = map (flip Qualified remoteDomainA . (.id)) targetUsers remoteBUsers = map (flip Qualified remoteDomainB . (.id)) targetUsers - config = UserSubsystemConfig visibility miniLocale False 100 + config = UserSubsystemConfig visibility miniLocale False 100 undefined localBackend = def {users = [viewer]} retrievedProfilesWithErrors :: ([(Qualified UserId, FederationError)], [UserProfile]) = runFederationStack localBackend online Nothing config $ @@ -281,7 +281,7 @@ spec = describe "UserSubsystem.Interpreter" do describe "getAccountsBy" do prop "GetBy userId when pending fails if not explicitly allowed" $ \(PendingNotEmptyIdentityStoredUser alice') email teamId invitationInfo localDomain visibility locale -> - let config = UserSubsystemConfig visibility locale False 100 + let config = UserSubsystemConfig visibility locale False 100 undefined alice = alice' { email = Just email, @@ -316,7 +316,7 @@ spec = describe "UserSubsystem.Interpreter" do prop "GetBy userId works for pending if explicitly queried" $ \(PendingNotEmptyIdentityStoredUser alice') email teamId invitationInfo localDomain visibility locale -> - let config = UserSubsystemConfig visibility locale True 100 + let config = UserSubsystemConfig visibility locale True 100 undefined alice = alice' { email = Just email, @@ -350,7 +350,7 @@ spec = describe "UserSubsystem.Interpreter" do in result === [mkUserFromStored localDomain locale alice] prop "GetBy handle when pending fails if not explicitly allowed" $ \(PendingNotEmptyIdentityStoredUser alice') handl email teamId invitationInfo localDomain visibility locale -> - let config = UserSubsystemConfig visibility locale True 100 + let config = UserSubsystemConfig visibility locale True 100 undefined alice = alice' { email = Just email, @@ -386,7 +386,7 @@ spec = describe "UserSubsystem.Interpreter" do prop "GetBy handle works for pending if explicitly queried" $ \(PendingNotEmptyIdentityStoredUser alice') handl email teamId invitationInfo localDomain visibility locale -> - let config = UserSubsystemConfig visibility locale True 100 + let config = UserSubsystemConfig visibility locale True 100 undefined alice = alice' { email = Just email, @@ -422,7 +422,7 @@ spec = describe "UserSubsystem.Interpreter" do prop "GetBy email does not filter by pending, missing identity or expired invitations" $ \(alice' :: StoredUser) email localDomain visibility locale -> - let config = UserSubsystemConfig visibility locale True 100 + let config = UserSubsystemConfig visibility locale True 100 undefined alice = alice' {email = Just email} localBackend = def @@ -436,7 +436,7 @@ spec = describe "UserSubsystem.Interpreter" do prop "GetBy userId does not return missing identity users, pending invitation off" $ \(NotPendingEmptyIdentityStoredUser alice) localDomain visibility locale -> - let config = UserSubsystemConfig visibility locale True 100 + let config = UserSubsystemConfig visibility locale True 100 undefined getBy = toLocalUnsafe localDomain $ def @@ -451,7 +451,7 @@ spec = describe "UserSubsystem.Interpreter" do prop "GetBy userId does not return missing identity users, pending invtation on" $ \(NotPendingEmptyIdentityStoredUser alice) localDomain visibility locale -> - let config = UserSubsystemConfig visibility locale True 100 + let config = UserSubsystemConfig visibility locale True 100 undefined getBy = toLocalUnsafe localDomain $ def @@ -466,7 +466,7 @@ spec = describe "UserSubsystem.Interpreter" do prop "GetBy pending user by id works if there is a valid invitation" $ \(PendingNotEmptyIdentityStoredUser alice') (email :: EmailAddress) teamId (invitationInfo :: StoredInvitation) localDomain visibility locale -> - let config = UserSubsystemConfig visibility locale True 100 + let config = UserSubsystemConfig visibility locale True 100 undefined emailKey = mkEmailKey email getBy = toLocalUnsafe localDomain $ @@ -495,7 +495,7 @@ spec = describe "UserSubsystem.Interpreter" do prop "GetBy pending user by id fails if there is no valid invitation" $ \(PendingNotEmptyIdentityStoredUser alice') (email :: EmailAddress) teamId localDomain visibility locale -> - let config = UserSubsystemConfig visibility locale True 100 + let config = UserSubsystemConfig visibility locale True 100 undefined emailKey = mkEmailKey email getBy = toLocalUnsafe localDomain $ @@ -516,7 +516,7 @@ spec = describe "UserSubsystem.Interpreter" do prop "GetBy pending user handle id works if there is a valid invitation" $ \(PendingNotEmptyIdentityStoredUser alice') (email :: EmailAddress) handl teamId (invitationInfo :: StoredInvitation) localDomain visibility locale -> - let config = UserSubsystemConfig visibility locale True 100 + let config = UserSubsystemConfig visibility locale True 100 undefined emailKey = mkEmailKey email getBy = toLocalUnsafe localDomain $ @@ -550,7 +550,7 @@ spec = describe "UserSubsystem.Interpreter" do prop "GetBy pending user by handle fails if there is no valid invitation" $ \(PendingNotEmptyIdentityStoredUser alice') (email :: EmailAddress) handl teamId localDomain visibility locale -> - let config = UserSubsystemConfig visibility locale True 100 + let config = UserSubsystemConfig visibility locale True 100 undefined emailKey = mkEmailKey email getBy = toLocalUnsafe localDomain $ @@ -814,3 +814,67 @@ spec = describe "UserSubsystem.Interpreter" do . interpretNoFederationStack localBackend Nothing def config $ getLocalUserAccountByUserKey (toLocalUnsafe localDomain userKey) in retrievedUser === Nothing + describe "Removing an email address" do + prop "Cannot remove an email of a non-existing user" $ \lusr config -> + let localBackend = def + result = + runNoFederationStack localBackend Nothing config $ + removeEmailEither lusr + in result === Left UserSubsystemProfileNotFound + prop "Cannot remove an email of a no-identity user" $ + \(locx :: Local ()) (NotPendingEmptyIdentityStoredUser user) config -> + let localBackend = def {users = [user]} + lusr = qualifyAs locx user.id + result = + runNoFederationStack localBackend Nothing config $ + removeEmailEither lusr + in result === Left UserSubsystemNoIdentity + prop "Cannot remove an email of a last-identity user" $ + \(locx :: Local ()) user' email sso config -> + let user = + user' + { activated = True, + email = email, + ssoId = if isNothing email then Just sso else Nothing + } + localBackend = def {users = [user]} + lusr = qualifyAs locx user.id + result = + runNoFederationStack localBackend Nothing config $ + removeEmailEither lusr + in result === Left UserSubsystemLastIdentity + prop "Successfully remove an email from an SSOId user" $ + \(locx :: Local ()) (NotPendingSSOIdWithEmailStoredUser user) config -> + let localBackend = def {users = [user]} + lusr = qualifyAs locx user.id + result = + runNoFederationStack localBackend Nothing config $ do + remRes <- removeEmailEither lusr + (remRes,) <$> gets users + in result === (Right (), [user {email = Nothing}]) + describe "Changing an email address" $ do + prop "Idempotent email change" $ + \(locx :: Local ()) (NotPendingStoredUser user') email config -> + let user = user' {email = Just email} + localBackend = def {users = [user]} + lusr = qualifyAs locx user.id + result = + runNoFederationStack localBackend Nothing config $ do + c <- requestEmailChange lusr email UpdateOriginWireClient + (c,) <$> gets users + in result === (ChangeEmailResponseIdempotent, [user]) + prop "Email change needing activation" $ + \(locx :: Local ()) (NotPendingStoredUser user') config -> + let email = unsafeEmailAddress "me" "example.com" + updatedEmail = unsafeEmailAddress "you" "example.com" + user = user' {email = Just email, managedBy = Nothing} + localBackend = def {users = [user]} + lusr = qualifyAs locx user.id + result = + runNoFederationStack localBackend Nothing config $ do + c <- requestEmailChange lusr updatedEmail UpdateOriginWireClient + (c,) <$> gets users + in result + === ( ChangeEmailResponseNeedsActivation, + [user {emailUnvalidated = Just updatedEmail}] + ) diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index d9000793018..f4e19a433bd 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -135,6 +135,7 @@ library Wire.UserSubsystem.Error Wire.UserSubsystem.HandleBlacklist Wire.UserSubsystem.Interpreter + Wire.UserSubsystem.UserSubsystemConfig Wire.VerificationCode Wire.VerificationCodeGen Wire.VerificationCodeStore @@ -148,6 +149,7 @@ library , amazonka , amazonka-core , amazonka-ses + , amqp , async , attoparsec , base @@ -231,6 +233,7 @@ test-suite wire-subsystems-tests -- cabal-fmt: expand test/unit other-modules: Spec + Wire.ActivationCodeStore.InterpreterSpec Wire.AuthenticationSubsystem.InterpreterSpec Wire.MiniBackend Wire.MockInterpreters diff --git a/services/background-worker/background-worker.cabal b/services/background-worker/background-worker.cabal index 387971a5fc0..795ade421a5 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.BackendDeadUserNotificationWatcher Wire.BackendNotificationPusher Wire.BackgroundWorker Wire.BackgroundWorker.Env @@ -29,7 +30,11 @@ library build-depends: aeson , amqp + , async , base + , bytestring + , bytestring-conversion + , cassandra-util , containers , exceptions , extended @@ -37,6 +42,7 @@ library , http-client , http2-manager , imports + , kan-extensions , metrics-wai , monad-control , prometheus-client @@ -45,6 +51,7 @@ library , servant-server , text , tinylog + , transformers , transformers-base , types-common , unliftio diff --git a/services/background-worker/background-worker.integration.yaml b/services/background-worker/background-worker.integration.yaml index c23798e63ed..9102981507b 100644 --- a/services/background-worker/background-worker.integration.yaml +++ b/services/background-worker/background-worker.integration.yaml @@ -8,6 +8,12 @@ federatorInternal: host: 127.0.0.1 port: 8097 +cassandra: + endpoint: + host: 127.0.0.1 + port: 9042 + keyspace: gundeck_test + rabbitmq: host: 127.0.0.1 port: 5671 diff --git a/services/background-worker/default.nix b/services/background-worker/default.nix index 6ccf66f8ac7..7e04820200e 100644 --- a/services/background-worker/default.nix +++ b/services/background-worker/default.nix @@ -5,8 +5,11 @@ { mkDerivation , aeson , amqp +, async , base , bytestring +, bytestring-conversion +, cassandra-util , containers , data-default , exceptions @@ -20,6 +23,7 @@ , http-types , http2-manager , imports +, kan-extensions , lib , metrics-wai , monad-control @@ -51,7 +55,11 @@ mkDerivation { libraryHaskellDepends = [ aeson amqp + async base + bytestring + bytestring-conversion + cassandra-util containers exceptions extended @@ -59,6 +67,7 @@ mkDerivation { http-client http2-manager imports + kan-extensions metrics-wai monad-control prometheus-client @@ -67,6 +76,7 @@ mkDerivation { servant-server text tinylog + transformers transformers-base types-common unliftio diff --git a/services/background-worker/src/Wire/BackendDeadUserNotificationWatcher.hs b/services/background-worker/src/Wire/BackendDeadUserNotificationWatcher.hs new file mode 100644 index 00000000000..1d24ab05c6e --- /dev/null +++ b/services/background-worker/src/Wire/BackendDeadUserNotificationWatcher.hs @@ -0,0 +1,143 @@ +{-# LANGUAGE BlockArguments #-} +{-# LANGUAGE RecordWildCards #-} + +module Wire.BackendDeadUserNotificationWatcher where + +import Cassandra +import Control.Concurrent (putMVar) +import Control.Monad.Codensity +import Control.Monad.Trans.Maybe +import Data.ByteString.Char8 qualified as BS +import Data.ByteString.Conversion +import Data.Id +import Data.Map qualified as Map +import Imports hiding (putMVar) +import Network.AMQP qualified as Q +import Network.AMQP.Extended +import Network.AMQP.Lifted qualified as QL +import Network.AMQP.Types +import System.Logger qualified as Log +import UnliftIO hiding (bracket, putMVar) +import UnliftIO.Exception (bracket) +import Wire.API.Notification +import Wire.BackgroundWorker.Env + +getLastDeathQueue :: Maybe FieldTable -> Maybe ByteString +getLastDeathQueue (Just (FieldTable headers)) = do + case Map.lookup "x-last-death-queue" headers of + Just (FVString str) -> pure str + _ -> Nothing +getLastDeathQueue Nothing = Nothing + +-- FUTUREWORK: what happens if messages expire _after_ we checked against cassandra here? +-- Should we have an async notification terminate this? +startConsumer :: Q.Channel -> AppT IO Q.ConsumerTag +startConsumer chan = do + env <- ask + markAsWorking BackendDeadUserNoticationWatcher + + cassandra <- asks (.cassandra) + + void . lift $ Q.declareQueue chan Q.newQueue {Q.queueName = userNotificationDlqName} + QL.consumeMsgs chan userNotificationDlqName Q.Ack $ \(msg, envelope) -> + if (msg.msgDeliveryMode == Just Q.NonPersistent) + then do + -- ignore transient messages, ack it so they don't clog the queue + lift $ Q.ackEnv envelope + else do + -- forward non-transient messages to the respective client + let dat = getLastDeathQueue msg.msgHeaders + let vals = fmap (BS.split '.') dat + case vals of + Nothing -> logHeaderError env msg.msgHeaders + Just ["user-notifications", uidBS, cidBS] -> do + m <- runMaybeT $ do + uid <- hoistMaybe $ fromByteString uidBS + cid <- hoistMaybe $ fromByteString cidBS + pure (uid, cid) + (uid, cid) <- maybe (logParseError env dat) pure m + markAsNeedsFullSync cassandra uid cid + lift $ Q.ackEnv envelope + _ -> void $ logParseError env dat + where + logHeaderError env headers = do + Log.err + env.logger + ( Log.msg (Log.val "Could not find x-last-death-queue in headers") + . Log.field "error_configuring_dead_letter_exchange" (show headers) + ) + error "Could not find x-last-death-queue in headers" + logParseError env dat = do + Log.err env.logger $ + Log.msg (Log.val "Could not parse msgHeaders into uid/cid for dead letter exchange message") + . Log.field "error_parsing_message" (show dat) + error "Could not parse msgHeaders into uid/cid for dead letter exchange message" + +markAsNeedsFullSync :: ClientState -> UserId -> ClientId -> AppT IO () +markAsNeedsFullSync cassandra uid cid = do + runClient cassandra do + retry x1 $ write missedNotifications (params LocalQuorum (uid, cid)) + where + missedNotifications :: PrepQuery W (UserId, ClientId) () + missedNotifications = + [sql| + INSERT INTO missed_notifications (user_id, client_id) + VALUES (?, ?) + |] + +startWorker :: + AmqpEndpoint -> + AppT IO (Async ()) +startWorker amqp = do + env <- ask + mVar <- newEmptyMVar + connOpts <- mkConnectionOpts amqp + + -- This function will open a connection to rabbitmq and start the consumer. + -- We use an mvar to signal when the connection is closed so we can re-open it. + -- If the empty mvar is filled, we know the connection itself was closed and we need to re-open it. + -- If the mvar is filled with a connection, we know the connection itself is fine, + -- so we only need to re-open the channel + let openConnection connM = do + -- keep track of whether the connection is being closed normally + closingRef <- newIORef False + + mConn <- lowerCodensity $ do + conn <- case connM of + Nothing -> do + -- Open the rabbit mq connection + conn <- Codensity + $ bracket + (liftIO $ Q.openConnection'' connOpts) + $ \conn -> do + writeIORef closingRef True + liftIO $ Q.closeConnection conn + -- We need to recover from connection closed by restarting it + liftIO $ Q.addConnectionClosedHandler conn True do + closing <- readIORef closingRef + unless closing $ do + Log.err env.logger $ + Log.msg (Log.val "BackendDeadUserNoticationWatcher: Connection closed.") + putMVar mVar Nothing + runAppT env $ markAsNotWorking BackendDeadUserNoticationWatcher + pure conn + Just conn -> pure conn + + -- After starting the connection, open the channel + chan <- Codensity $ bracket (liftIO $ Q.openChannel conn) (liftIO . Q.closeChannel) + + -- If the channel stops, we need to re-open + liftIO $ Q.addChannelExceptionHandler chan $ \e -> do + unless (Q.isNormalChannelClose e) $ + Log.err env.logger $ + Log.msg (Log.val "BackendDeadUserNoticationWatcher: Caught exception in RabbitMQ channel.") + . Log.field "exception" (displayException e) + runAppT env $ markAsNotWorking BackendDeadUserNoticationWatcher + putMVar mVar (Just conn) + + -- Set up the consumer + void $ Codensity $ bracket (startConsumer chan) (liftIO . Q.cancelConsumer chan) + lift $ takeMVar mVar + openConnection mConn + + async (openConnection Nothing) diff --git a/services/background-worker/src/Wire/BackendNotificationPusher.hs b/services/background-worker/src/Wire/BackendNotificationPusher.hs index 6a6cf2f7f62..68f9e25dd54 100644 --- a/services/background-worker/src/Wire/BackendNotificationPusher.hs +++ b/services/background-worker/src/Wire/BackendNotificationPusher.hs @@ -71,7 +71,7 @@ pushNotification runningFlag targetDomain (msg, envelope) = do -- does cause problems when trying to deregister consumers from the channel. This is because -- the internal mechanism to remove a consumer goes via the same notification handling code -- as messages from the Rabbit server. If the thread is tied up in the recovery code we - -- can't cancel the consumer, and the calling code will block until the cancelation message + -- can't cancel the consumer, and the calling code will block until the cancellation message -- can be processed. -- Luckily, we can async this loop and carry on as usual due to how we have the channel setup. async $ @@ -92,7 +92,7 @@ pushNotification runningFlag targetDomain (msg, envelope) = do Left eBN -> do Log.err $ Log.msg - ( Log.val "Cannot parse a queued message as s notification " + ( Log.val "Cannot parse a queued message as a notification " <> "nor as a bundle; the message will be ignored" ) . Log.field "domain" (domainText targetDomain) @@ -201,9 +201,9 @@ pairedMaximumOn f = maximumBy (compare `on` snd) . map (id &&& f) -- Consumers is passed in explicitly so that cleanup code has a reference to the consumer tags. startPusher :: RabbitMqAdmin.AdminAPI (Servant.AsClientT IO) -> IORef (Map Domain (Q.ConsumerTag, MVar ())) -> Q.Channel -> AppT IO () startPusher adminClient consumersRef chan = do + markAsWorking BackendNotificationPusher -- This ensures that we receive notifications 1 by 1 which ensures they are -- delivered in order. - markAsWorking BackendNotificationPusher lift $ Q.qos chan 0 1 False -- Make sure threads aren't dangling if/when this async thread is killed let cleanup :: (Exception e, MonadThrow m, MonadIO m) => e -> m () @@ -282,7 +282,7 @@ getRemoteDomains adminClient = do go :: AppT IO [Domain] go = do vhost <- asks rabbitmqVHost - queues <- liftIO $ listQueuesByVHost adminClient vhost + queues <- liftIO $ listQueuesByVHost adminClient vhost (Just "backend-notifications\\..*") (Just True) let notifQueuesSuffixes = mapMaybe (\q -> Text.stripPrefix "backend-notifications." q.name) queues catMaybes <$> traverse (\d -> either (\e -> logInvalidDomain d e >> pure Nothing) (pure . Just) $ mkDomain d) notifQueuesSuffixes logInvalidDomain d e = @@ -291,6 +291,7 @@ getRemoteDomains adminClient = do . Log.field "queue" ("backend-notifications." <> d) . Log.field "error" e +-- FUTUREWORK: rework this in the vein of DeadLetterWatcher startWorker :: AmqpEndpoint -> AppT IO (IORef (Maybe Q.Channel), IORef (Map Domain (Q.ConsumerTag, MVar ()))) startWorker rabbitmqOpts = do env <- ask diff --git a/services/background-worker/src/Wire/BackgroundWorker.hs b/services/background-worker/src/Wire/BackgroundWorker.hs index 17b45f71ecd..d71ca7658e6 100644 --- a/services/background-worker/src/Wire/BackgroundWorker.hs +++ b/services/background-worker/src/Wire/BackgroundWorker.hs @@ -2,6 +2,7 @@ module Wire.BackgroundWorker where +import Control.Concurrent.Async (cancel) import Data.Domain import Data.Map.Strict qualified as Map import Data.Metrics.Servant qualified as Metrics @@ -14,6 +15,7 @@ import Servant import Servant.Server.Generic import System.Logger qualified as Log import Util.Options +import Wire.BackendDeadUserNotificationWatcher qualified as DeadUserNotificationWatcher import Wire.BackendNotificationPusher qualified as BackendNotificationPusher import Wire.BackgroundWorker.Env import Wire.BackgroundWorker.Health qualified as Health @@ -24,10 +26,14 @@ run opts = do env <- mkEnv opts let amqpEP = either id demoteOpts opts.rabbitmq.unRabbitMqOpts (notifChanRef, notifConsumersRef) <- runAppT env $ BackendNotificationPusher.startWorker amqpEP + deadWatcherAsync <- runAppT env $ DeadUserNotificationWatcher.startWorker amqpEP let -- cleanup will run in a new thread when the signal is caught, so we need to use IORefs and -- specific exception types to message threads to clean up l = logger env cleanup = do + -- cancel the dead letter watcher + cancel deadWatcherAsync + -- Notification pusher thread Log.info l $ Log.msg (Log.val "Cancelling the notification pusher thread") readIORef notifChanRef >>= traverse_ \chan -> do diff --git a/services/background-worker/src/Wire/BackgroundWorker/Env.hs b/services/background-worker/src/Wire/BackgroundWorker/Env.hs index dcf89d56d41..1df7805d476 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Env.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Env.hs @@ -3,6 +3,8 @@ module Wire.BackgroundWorker.Env where +import Cassandra (ClientState) +import Cassandra.Util (defInitCassandra) import Control.Monad.Base import Control.Monad.Catch import Control.Monad.Trans.Control @@ -27,6 +29,7 @@ type IsWorking = Bool -- | Eventually this will be a sum type of all the types of workers data Worker = BackendNotificationPusher + | BackendDeadUserNoticationWatcher deriving (Show, Eq, Ord) data Env = Env @@ -39,7 +42,8 @@ data Env = Env defederationTimeout :: ResponseTimeout, backendNotificationMetrics :: BackendNotificationMetrics, backendNotificationsConfig :: BackendNotificationsConfig, - statuses :: IORef (Map Worker IsWorking) + statuses :: IORef (Map Worker IsWorking), + cassandra :: ClientState } data BackendNotificationMetrics = BackendNotificationMetrics @@ -57,8 +61,9 @@ mkBackendNotificationMetrics = mkEnv :: Opts -> IO Env mkEnv opts = do - http2Manager <- initHttp2Manager logger <- Log.mkLogger opts.logLevel Nothing opts.logFormat + cassandra <- defInitCassandra opts.cassandra logger + http2Manager <- initHttp2Manager httpManager <- newManager defaultManagerSettings let federatorInternal = opts.federatorInternal defederationTimeout = diff --git a/services/background-worker/src/Wire/BackgroundWorker/Options.hs b/services/background-worker/src/Wire/BackgroundWorker/Options.hs index cdbeb1e5024..f9055d89e0b 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Options.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Options.hs @@ -14,7 +14,8 @@ data Opts = Opts rabbitmq :: !RabbitMqOpts, -- | Seconds, Nothing for no timeout defederationTimeout :: Maybe Int, - backendNotificationPusher :: BackendNotificationsConfig + backendNotificationPusher :: BackendNotificationsConfig, + cassandra :: CassandraOpts } deriving (Show, Generic) diff --git a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs index 416a2653f82..7e63fb10f44 100644 --- a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs +++ b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs @@ -267,6 +267,7 @@ spec = do ] logger <- Logger.new Logger.defSettings httpManager <- newManager defaultManagerSettings + let cassandra = undefined let federatorInternal = Endpoint "localhost" 8097 http2Manager = undefined statuses = undefined @@ -283,6 +284,7 @@ spec = do it "should retry fetching domains if a request fails" $ do mockAdmin <- newMockRabbitMqAdmin True ["backend-notifications.foo.example"] logger <- Logger.new Logger.defSettings + let cassandra = undefined httpManager <- newManager defaultManagerSettings let federatorInternal = Endpoint "localhost" 8097 http2Manager = undefined @@ -346,11 +348,13 @@ mockApi :: MockRabbitMqAdmin -> AdminAPI (AsServerT Servant.Handler) mockApi mockAdmin = AdminAPI { listQueuesByVHost = mockListQueuesByVHost mockAdmin, - deleteQueue = mockListDeleteQueue mockAdmin + deleteQueue = mockListDeleteQueue mockAdmin, + listConnectionsByVHost = mockListConnectionsByVHost mockAdmin, + deleteConnection = mockDeleteConnection mockAdmin } -mockListQueuesByVHost :: MockRabbitMqAdmin -> Text -> Servant.Handler [Queue] -mockListQueuesByVHost MockRabbitMqAdmin {..} vhost = do +mockListQueuesByVHost :: MockRabbitMqAdmin -> Text -> Maybe Text -> Maybe Bool -> Servant.Handler [Queue] +mockListQueuesByVHost MockRabbitMqAdmin {..} vhost _ _ = do atomically $ modifyTVar listQueuesVHostCalls (<> [vhost]) readTVarIO broken >>= \case True -> throwError $ Servant.err500 @@ -360,6 +364,12 @@ mockListDeleteQueue :: MockRabbitMqAdmin -> Text -> Text -> Servant.Handler NoCo mockListDeleteQueue _ _ _ = do pure NoContent +mockListConnectionsByVHost :: MockRabbitMqAdmin -> Text -> Servant.Handler [Connection] +mockListConnectionsByVHost _ _ = pure [] + +mockDeleteConnection :: MockRabbitMqAdmin -> Text -> Servant.Handler NoContent +mockDeleteConnection _ _ = pure NoContent + mockRabbitMqAdminApp :: MockRabbitMqAdmin -> Application mockRabbitMqAdminApp mockAdmin = genericServe (mockApi mockAdmin) diff --git a/services/background-worker/test/Test/Wire/Util.hs b/services/background-worker/test/Test/Wire/Util.hs index 57ccf6bf0e1..fb06a79f686 100644 --- a/services/background-worker/test/Test/Wire/Util.hs +++ b/services/background-worker/test/Test/Wire/Util.hs @@ -14,6 +14,7 @@ testEnv :: IO Env testEnv = do http2Manager <- initHttp2Manager logger <- Logger.new Logger.defSettings + let cassandra = undefined statuses <- newIORef mempty backendNotificationMetrics <- mkBackendNotificationMetrics httpManager <- newManager defaultManagerSettings diff --git a/services/brig/deb/opt/brig/template-version b/services/brig/deb/opt/brig/template-version index 5c41189b952..33fa1fdb55b 100644 --- a/services/brig/deb/opt/brig/template-version +++ b/services/brig/deb/opt/brig/template-version @@ -1 +1 @@ -v1.0.122 +v1.0.124 diff --git a/services/brig/deb/opt/brig/templates/de/team/email/new-team-owner-welcome-subject.txt b/services/brig/deb/opt/brig/templates/de/team/email/new-team-owner-welcome-subject.txt new file mode 100644 index 00000000000..7fcb109ea4c --- /dev/null +++ b/services/brig/deb/opt/brig/templates/de/team/email/new-team-owner-welcome-subject.txt @@ -0,0 +1 @@ +Sie sind einem Team auf ${brand} beigetreten \ No newline at end of file diff --git a/services/brig/deb/opt/brig/templates/de/team/email/new-team-owner-welcome.html b/services/brig/deb/opt/brig/templates/de/team/email/new-team-owner-welcome.html new file mode 100644 index 00000000000..aa48804240b --- /dev/null +++ b/services/brig/deb/opt/brig/templates/de/team/email/new-team-owner-welcome.html @@ -0,0 +1 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en" style="background:#fff!important"><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"><meta name="viewport" content="width=device-width"><title>Sie sind einem Team auf ${brand} beigetreten

${brand_label_url}

Herzlichen Glückwunsch ${name}.

Wir haben Ihr privates Benutzerkonto ${email} in ein Team-Konto übertragen, einschließlich all Ihrer Unterhaltungen und Ihres Gesprächsverlaufs. Sie sind jetzt Besitzer des Teams (${team_name}).

 

Laden Sie andere Personen ein, Ihrem Team beizutreten, und passen Sie die Einstellungen im Team-Management an.

 
TEAM-MANAGEMENT ÖFFNEN
 

Wenn Sie die Schaltfläche nicht auswählen können, kopieren Sie diesen Link und fügen Sie ihn in Ihren Browser ein:

${url}

Wenn Sie Fragen haben, dann kontaktieren Sie uns bitte.

Team ID: ${team_id}

                                                           
\ No newline at end of file diff --git a/services/brig/deb/opt/brig/templates/de/team/email/new-team-owner-welcome.txt b/services/brig/deb/opt/brig/templates/de/team/email/new-team-owner-welcome.txt new file mode 100644 index 00000000000..b76a2fb2f1f --- /dev/null +++ b/services/brig/deb/opt/brig/templates/de/team/email/new-team-owner-welcome.txt @@ -0,0 +1,27 @@ +[${brand_logo}] + +${brand_label_url} [${brand_url}] + +HERZLICHEN GLÜCKWUNSCH ${name}. +Wir haben Ihr privates Benutzerkonto ${email} in ein Team-Konto übertragen, +einschließlich all Ihrer Unterhaltungen und Ihres Gesprächsverlaufs. Sie sind +jetzt Besitzer des Teams (${team_name}). + +Laden Sie andere Personen ein, Ihrem Team beizutreten, und passen Sie die +Einstellungen im Team-Management an. + +TEAM-MANAGEMENT ÖFFNEN [${url}]Wenn Sie die Schaltfläche nicht auswählen können, +kopieren Sie diesen Link und fügen Sie ihn in Ihren Browser ein: + +${url} + +Wenn Sie Fragen haben, dann kontaktieren Sie uns [${support}] bitte. + +Team ID: ${team_id} + + +-------------------------------------------------------------------------------- + +Datenschutzrichtlinien und Nutzungsbedingungen [${legal}] · Missbrauch melden +[${misuse}] +${copyright}. ALLE RECHTE VORBEHALTEN. \ No newline at end of file diff --git a/services/brig/deb/opt/brig/templates/en/team/email/new-team-owner-welcome-subject.txt b/services/brig/deb/opt/brig/templates/en/team/email/new-team-owner-welcome-subject.txt new file mode 100644 index 00000000000..bc803c8137e --- /dev/null +++ b/services/brig/deb/opt/brig/templates/en/team/email/new-team-owner-welcome-subject.txt @@ -0,0 +1 @@ +You joined a team on ${brand} \ No newline at end of file diff --git a/services/brig/deb/opt/brig/templates/en/team/email/new-team-owner-welcome.html b/services/brig/deb/opt/brig/templates/en/team/email/new-team-owner-welcome.html new file mode 100644 index 00000000000..bb0817f6288 --- /dev/null +++ b/services/brig/deb/opt/brig/templates/en/team/email/new-team-owner-welcome.html @@ -0,0 +1 @@ +You joined a team on ${brand}

${brand_label_url}

Congratulations ${name}.

We transferred your personal account ${email} into a team account, including all your conversations and history. You are now the owner of the team (${team_name}).

 

Invite other people to join your team and adjust settings in your team management dashboard.

 
OPEN TEAM MANAGEMENT
 

If you can’t select the button, copy and paste this link to your browser:

${url}

If you have any questions, please contact us.

Team ID: ${team_id}

                                                           
\ No newline at end of file diff --git a/services/brig/deb/opt/brig/templates/en/team/email/new-team-owner-welcome.txt b/services/brig/deb/opt/brig/templates/en/team/email/new-team-owner-welcome.txt new file mode 100644 index 00000000000..28ce003b96c --- /dev/null +++ b/services/brig/deb/opt/brig/templates/en/team/email/new-team-owner-welcome.txt @@ -0,0 +1,26 @@ +[${brand_logo}] + +${brand_label_url} [${brand_url}] + +CONGRATULATIONS ${name}. +We transferred your personal account ${email} into a team account, including all +your conversations and history. You are now the owner of the team (${team_name} +). + +Invite other people to join your team and adjust settings in your team +management dashboard. + +OPEN TEAM MANAGEMENT [${url}]If you can’t select the button, copy and paste this +link to your browser: + +${url} + +If you have any questions, please contact us [${support}]. + +Team ID: ${team_id} + + +-------------------------------------------------------------------------------- + +Privacy policy and terms of use [${legal}] · Report Misuse [${misuse}] +${copyright}. ALL RIGHTS RESERVED. \ No newline at end of file diff --git a/services/brig/deb/opt/brig/templates/index.html b/services/brig/deb/opt/brig/templates/index.html index 76b2de86f7b..dee7a019484 100644 --- a/services/brig/deb/opt/brig/templates/index.html +++ b/services/brig/deb/opt/brig/templates/index.html @@ -4,4 +4,4 @@ link.rel = 'stylesheet'; link.href = '//cdnjs.cloudflare.com/ajax/libs/flag-icon-css/2.9.0/css/flag-icon.min.css'; document.head.appendChild(link); - }
 

Wire Email Templates Preview

Click the links below to display the content of each message:

Provider
  1. Activationtxt
  2. Approval confirmtxt
  3. Approval requesttxt
Team
  1. Invitationtxt
  2. New member welcometxt
  3. Migration from private to team usertxt
User
  1. Activationtxt
  2. Deletiontxt
  3. New clienttxt
  4. Password resettxt
  5. Updatetxt
  6. Verificationtxt
  7. Team activationtxt
  8. Second factor verification for logintxt
  9. Second factor verification create SCIM tokentxt
  10. Second factor verification delete teamtxt
Billing
  1. Suspensiontxt

For source and instructions, see github.com/wireapp/wire-emails or visit the Crowdin project to help with translations.

                                                           
\ No newline at end of file + }
 

Wire Email Templates Preview

Click the links below to display the content of each message:

Provider
  1. Activationtxt
  2. Approval confirmtxt
  3. Approval requesttxt
Team
  1. Invitationtxt
  2. New member welcometxt
  3. New team owner welcometxt
  4. Migration from private to team usertxt
User
  1. Activationtxt
  2. Deletiontxt
  3. New clienttxt
  4. Password resettxt
  5. Updatetxt
  6. Verificationtxt
  7. Team activationtxt
  8. Second factor verification for logintxt
  9. Second factor verification create SCIM tokentxt
  10. Second factor verification delete teamtxt
Billing
  1. Suspensiontxt

For source and instructions, see github.com/wireapp/wire-emails or visit the Crowdin project to help with translations.

                                                           
\ No newline at end of file diff --git a/services/brig/deb/opt/brig/templates/version b/services/brig/deb/opt/brig/templates/version index 5c41189b952..33fa1fdb55b 100644 --- a/services/brig/deb/opt/brig/templates/version +++ b/services/brig/deb/opt/brig/templates/version @@ -1 +1 @@ -v1.0.122 +v1.0.124 diff --git a/services/brig/docs/swagger-v7.json b/services/brig/docs/swagger-v7.json new file mode 100644 index 00000000000..3f60be27d75 --- /dev/null +++ b/services/brig/docs/swagger-v7.json @@ -0,0 +1,31674 @@ +{ + "components": { + "schemas": { + "ASCII": { + "example": "aGVsbG8", + "type": "string" + }, + "AcceptTeamInvitation": { + "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" + }, + "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" + }, + "AccountStatus": { + "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" + ], + "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" + }, + "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": { + "description": "S3 asset key for an icon image with retention information.", + "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/Currency.Alpha" + }, + "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" + }, + "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" + }, + "ClientCapabilityListV7": { + "items": { + "$ref": "#/components/schemas/ClientCapabilityV7" + }, + "type": "array" + }, + "ClientCapabilityV7": { + "enum": [ + "legalhold-implicit-consent" + ], + "type": "string" + }, + "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" + }, + "ClientListV7": { + "items": { + "$ref": "#/components/schemas/ClientV7" + }, + "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" + }, + "ClientV7": { + "properties": { + "capabilities": { + "$ref": "#/components/schemas/ClientCapabilityListV7" + }, + "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/EpochTimestamp" + }, + "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" + }, + "ConversationV3": { + "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/EpochTimestamp" + }, + "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" + }, + "ConversationV6": { + "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" + }, + "CreateScimTokenResponseV7": { + "properties": { + "info": { + "$ref": "#/components/schemas/ScimTokenInfoV7" + }, + "token": { + "type": "string" + } + }, + "required": [ + "token", + "info" + ], + "type": "object" + }, + "CreateScimTokenV7": { + "properties": { + "description": { + "type": "string" + }, + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "verification_code": { + "$ref": "#/components/schemas/ASCII" + } + }, + "required": [ + "description" + ], + "type": "object" + }, + "CreateUserTeam": { + "properties": { + "team_id": { + "$ref": "#/components/schemas/UUID" + }, + "team_name": { + "type": "string" + } + }, + "required": [ + "team_id", + "team_name" + ], + "type": "object" + }, + "Currency.Alpha": { + "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" + }, + "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" + }, + "EpochTimestamp": { + "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/EpochTimestamp" + }, + "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" + }, + "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" + }, + "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" + }, + "Id_AuthnRequest": { + "properties": { + "iD": { + "$ref": "#/components/schemas/XmlText" + } + }, + "required": [ + "iD" + ], + "type": "object" + }, + "Invitation": { + "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" + }, + "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": { + "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" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "InvitationUserView": { + "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" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "url": { + "$ref": "#/components/schemas/URIRef_Absolute" + } + }, + "required": [ + "team", + "id", + "created_at", + "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_SomeKey": { + "properties": { + "conversation": { + "$ref": "#/components/schemas/Conversation" + }, + "public_keys": { + "$ref": "#/components/schemas/MLSKeysByPurpose" + } + }, + "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" + }, + "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" + }, + "NewClientV7": { + "properties": { + "capabilities": { + "$ref": "#/components/schemas/ClientCapabilityListV7" + }, + "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/ServiceTag" + }, + "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": { + "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": { + "items": {}, + "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/TurnUsername" + } + }, + "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" + }, + "SFTUsername": { + "description": "String containing the SFT username", + "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" + }, + "ScimTokenInfoV7": { + "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" + }, + "ScimTokenListV7": { + "properties": { + "tokens": { + "items": { + "$ref": "#/components/schemas/ScimTokenInfoV7" + }, + "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/ServiceTag" + }, + "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": { + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "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" + }, + "type": "array" + } + }, + "required": [ + "id", + "provider", + "name", + "summary", + "description", + "assets", + "tags", + "enabled" + ], + "type": "object" + }, + "ServiceProfilePage": { + "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" + }, + "ServiceTag": { + "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" + }, + "ServiceTagList": { + "items": { + "$ref": "#/components/schemas/ServiceTag" + }, + "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" + }, + "TurnUsername": { + "description": "Username to use for authenticating against the given TURN servers", + "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" + }, + "UpdateClientV7": { + "properties": { + "capabilities": { + "$ref": "#/components/schemas/ClientCapabilityListV7" + }, + "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/ServiceTag" + }, + "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" + }, + "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" + }, + "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" + }, + "status": { + "$ref": "#/components/schemas/AccountStatus" + }, + "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", + "status", + "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": [ + 8 + ], + "domain": "example.com", + "federation": false, + "supported": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ] + }, + "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, + 8 + ], + "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": { + "/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" + } + }, + "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.", + "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" + } + } + }, + "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.", + "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" + } + }, + "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.", + "operationId": "post-activate", + "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", + "operationId": "post-activate-send", + "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", + "operationId": "get-version", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/VersionInfo" + } + } + }, + "description": "" + } + } + } + }, + "/assets": { + "post": { + "description": " [internal route ID: \"assets-upload\"]\n\n", + "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" + } + }, + "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": " [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" + } + } + }, + "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", + "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" + } + }, + "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": { + "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@v7\"]\n\n", + "operationId": "bot-get-client@v7", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientV7" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientV7" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "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" + } + } + }, + "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" + } + }, + "/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" + } + }, + "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" + } + }, + "/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/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", + "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" + } + } + }, + "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" + }, + "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/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.", + "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" + } + }, + "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", + "operationId": "get-calls-config", + "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", + "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" + } + } + }, + "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@v7\"]\n\n", + "operationId": "list-clients@v7", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientListV7" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientListV7" + } + } + }, + "description": "List of clients" + } + }, + "summary": "List the registered clients" + }, + "post": { + "description": " [internal route ID: \"add-client@v7\"]\n\n", + "operationId": "add-client@v7", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewClientV7" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientV7" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientV7" + } + } + }, + "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 as JWT DPoP token in the `DPoP` header.", + "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" + } + }, + "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", + "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/DeleteClient" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Client deleted" + } + }, + "summary": "Delete an existing client" + }, + "get": { + "description": " [internal route ID: \"get-client@v7\"]\n\n", + "operationId": "get-client@v7", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientV7" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientV7" + } + } + }, + "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@v7\"]\n\n", + "operationId": "update-client@v7", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UpdateClientV7" + } + } + }, + "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@v7\"]\n\n", + "operationId": "get-client-capabilities@v7", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientCapabilityListV7" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientCapabilityListV7" + } + } + }, + "description": "capabilities" + } + }, + "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" + } + }, + "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", + "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" + } + }, + "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\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" + } + } + }, + "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", + "operationId": "create-group-conversation", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewConv" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV6" + } + } + }, + "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.", + "operationId": "code-check", + "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", + "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" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 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" + } + } + }, + "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\n", + "operationId": "list-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.", + "operationId": "list-conversation-ids", + "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", + "operationId": "get-mls-self-conversation", + "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/self": { + "post": { + "description": " [internal route ID: \"create-self-conversation\"]\n\n", + "operationId": "create-self-conversation", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV6" + } + } + }, + "description": "Conversation existed", + "headers": { + "Location": { + "description": "Conversation ID", + "schema": { + "format": "uuid", + "type": "string" + } + } + } + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV6" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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\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}/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" + } + } + }, + "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\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" + } + }, + "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.", + "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" + } + } + }, + "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" + } + } + }, + "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\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" + } + } + }, + "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.", + "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" + } + }, + "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.", + "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" + } + } + }, + "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\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" + } + } + }, + "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.", + "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" + } + } + }, + "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/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\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" + } + }, + "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\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/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.", + "operationId": "update-conversation-name-deprecated", + "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", + "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" + } + }, + "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", + "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" + } + }, + "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`", + "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" + } + } + }, + "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", + "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/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}/message-timer": { + "put": { + "deprecated": true, + "description": " [internal route ID: \"update-conversation-message-timer-unqualified\"]\n\nUse `/conversations/:domain/:cnv/message-timer` instead.", + "operationId": "update-conversation-message-timer-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/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.", + "operationId": "update-conversation-name-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/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.", + "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/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.", + "operationId": "update-conversation-receipt-mode-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/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", + "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" + } + }, + "/conversations/{cnv}/self": { + "get": { + "deprecated": true, + "description": " [internal route ID: \"get-conversation-self-unqualified\"]\n\n", + "operationId": "get-conversation-self-unqualified", + "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.", + "operationId": "update-conversation-self-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/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", + "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" + } + }, + "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", + "operationId": "remove-cookies", + "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", + "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" + } + } + }, + "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" + } + } + }, + "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`", + "operationId": "get-all-feature-configs-for-user", + "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" + } + }, + "/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" + } + } + }, + "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\n", + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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/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.", + "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" + } + } + }, + "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" + } + } + }, + "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.", + "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" + } + }, + "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\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. For backwards compatibility, the `ciphersuite` parameter is optional, defaulting to ciphersuite 0x0001 when omitted.", + "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. 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", + "operationId": "mls-key-packages-delete", + "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.", + "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" + } + } + }, + "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. 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", + "operationId": "mls-key-packages-count", + "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.", + "operationId": "mls-message", + "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.", + "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" + } + }, + "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", + "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" + } + }, + "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", + "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" + } + }, + "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", + "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" + } + }, + "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.", + "operationId": "get-oauth-applications", + "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}/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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + }, + "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.", + "operationId": "revoke-oauth-refresh-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.", + "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" + } + } + }, + "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\":[]}'.", + "operationId": "onboarding", + "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." + } + }, + "/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/NewConv" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV3" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV3" + } + } + }, + "description": "Conversation existed", + "headers": { + "Location": { + "description": "Conversation ID", + "schema": { + "format": "uuid", + "type": "string" + } + } + } + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV3" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV3" + } + } + }, + "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" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSOne2OneConversation_SomeKey" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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'.", + "operationId": "post-password-reset-key-deprecated", + "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", + "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" + } + } + }, + "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" + } + }, + "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", + "operationId": "provider-update", + "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", + "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" + } + }, + "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": { + "description": " [internal route ID: (\"assets-upload-v3\", provider)]\n\n", + "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" + } + }, + "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": { + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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", + "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", + "operationId": "get-provider-services", + "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", + "operationId": "post-provider-services", + "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", + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + }, + "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", + "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" + }, + "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" + } + } + }, + "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", + "operationId": "get-push-tokens", + "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", + "operationId": "register-push-token", + "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", + "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", + "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": { + "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@v7\"]\n\n", + "operationId": "auth-tokens-list@v7", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ScimTokenListV7" + } + } + }, + "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@v7\"]\n\n", + "operationId": "auth-tokens-create@v7", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CreateScimTokenV7" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CreateScimTokenResponseV7" + } + } + }, + "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", + "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.", + "operationId": "delete-self", + "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`", + "operationId": "get-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\n", + "operationId": "put-self", + "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.", + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Supported protocols changed" + } + }, + "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" + } + } + }, + "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/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": "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": "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/InvitationUserView" + } + } + }, + "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'.", + "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" + } + } + }, + "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" + } + } + }, + "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", + "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" + } + } + }, + "description": "" + } + }, + "summary": "Get whitelisted services by team id" + } + }, + "/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" + } + } + }, + "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", + "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" + } + } + }, + "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" + } + } + }, + "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", + "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" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "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" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "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/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", + "operationId": "get_AppLockConfig", + "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", + "operationId": "put_AppLockConfig", + "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", + "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/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", + "operationId": "get_ConferenceCallingConfig", + "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", + "operationId": "put_ConferenceCallingConfig", + "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", + "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/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", + "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/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", + "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/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.

", + "operationId": "get_EnforceFileDownloadLocationConfig", + "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.

", + "operationId": "put_EnforceFileDownloadLocationConfig", + "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", + "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/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", + "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/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", + "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/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", + "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/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", + "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/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", + "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/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", + "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/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", + "operationId": "get_MLSConfig", + "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", + "operationId": "put_MLSConfig", + "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", + "operationId": "get_MlsE2EIdConfig", + "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", + "operationId": "put_MlsE2EIdConfig", + "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", + "operationId": "get_MlsMigrationConfig", + "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", + "operationId": "put_MlsMigrationConfig", + "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", + "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/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", + "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/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", + "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/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", + "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/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", + "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/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", + "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/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", + "operationId": "get_SelfDeletingMessagesConfig", + "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", + "operationId": "put_SelfDeletingMessagesConfig", + "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", + "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/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", + "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/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", + "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/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", + "operationId": "get_ValidateSAMLEmailsConfig", + "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`.", + "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" + } + } + }, + "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", + "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" + } + }, + "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.", + "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" + } + } + }, + "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-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" + } + }, + "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\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" + } + } + }, + "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", + "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" + } + } + }, + "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" + } + } + }, + "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)", + "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" + } + } + }, + "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", + "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" + } + } + }, + "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" + } + } + }, + "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", + "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" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 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": 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" + } + } + }, + "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", + "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" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 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" + } + } + ], + "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", + "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" + } + } + }, + "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" + } + } + }, + "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" + } + }, + "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" + } + }, + "/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" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUserTeam" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CreateUserTeam" + } + } + }, + "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" + } + }, + "/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" + } + } + }, + "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" + } + }, + "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\n", + "operationId": "get-user-clients-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": { + "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\n", + "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" + } + } + }, + "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" + } + } + }, + "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/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.", + "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" + } + } + }, + "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" + } + }, + "/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" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Verification code sent." + } + }, + "summary": "Send a verification code to a given email address." + } + } + }, + "security": [ + { + "ZAuth": [] + } + ], + "servers": [ + { + "url": "/v8" + } + ] +} diff --git a/services/brig/src/Brig/API/Auth.hs b/services/brig/src/Brig/API/Auth.hs index 021ca38aabc..3821d6b4534 100644 --- a/services/brig/src/Brig/API/Auth.hs +++ b/services/brig/src/Brig/API/Auth.hs @@ -20,7 +20,6 @@ module Brig.API.Auth where import Brig.API.Error import Brig.API.Handler import Brig.API.Types -import Brig.API.User import Brig.App import Brig.Options import Brig.User.Auth qualified as Auth @@ -39,6 +38,7 @@ import Network.HTTP.Types import Network.Wai.Utilities ((!>>)) import Network.Wai.Utilities.Error qualified as Wai import Polysemy +import Polysemy.Error (Error) import Polysemy.Input import Polysemy.TinyLog (TinyLog) import Wire.API.Error @@ -57,7 +57,10 @@ import Wire.Events (Events) import Wire.GalleyAPIAccess import Wire.UserKeyStore import Wire.UserStore -import Wire.UserSubsystem +import Wire.UserSubsystem (UpdateOriginType (..), UserSubsystem) +import Wire.UserSubsystem qualified as User +import Wire.UserSubsystem.Error +import Wire.UserSubsystem.UserSubsystemConfig import Wire.VerificationCodeSubsystem (VerificationCodeSubsystem) accessH :: @@ -128,23 +131,29 @@ logout :: (TokenPair u a) => NonEmpty (Token u) -> Maybe (Token a) -> Handler r logout _ Nothing = throwStd authMissingToken logout uts (Just at) = Auth.logout (List1 uts) at !>> zauthError -changeSelfEmailH :: +changeSelfEmail :: ( Member BlockListStore r, Member UserKeyStore r, Member EmailSubsystem r, - Member UserSubsystem r + Member UserSubsystem r, + Member UserStore r, + Member ActivationCodeStore r, + Member (Error UserSubsystemError) r, + Member (Input UserSubsystemConfig) r ) => [Either Text SomeUserToken] -> Maybe (Either Text SomeAccessToken) -> EmailUpdate -> Handler r ChangeEmailResponse -changeSelfEmailH uts' mat' up = do +changeSelfEmail uts' mat' up = do uts <- handleTokenErrors uts' mat <- traverse handleTokenError mat' toks <- partitionTokens uts mat usr <- either (uncurry validateCredentials) (uncurry validateCredentials) toks + lusr <- qualifyLocal usr let email = euEmail up - changeSelfEmail usr email UpdateOriginWireClient + lift . liftSem $ + User.requestEmailChange lusr email UpdateOriginWireClient validateCredentials :: (TokenPair u a) => diff --git a/services/brig/src/Brig/API/Client.hs b/services/brig/src/Brig/API/Client.hs index 9a94f880659..d7db42be04b 100644 --- a/services/brig/src/Brig/API/Client.hs +++ b/services/brig/src/Brig/API/Client.hs @@ -47,6 +47,8 @@ module Brig.API.Client ) where +import Brig.API.Error (clientError) +import Brig.API.Handler (Handler) import Brig.API.Types import Brig.API.Util import Brig.App @@ -76,13 +78,14 @@ import Data.Domain import Data.HavePendingInvitations import Data.Id (ClientId, ConnId, UserId) import Data.List.Split (chunksOf) -import Data.Map.Strict qualified as Map +import Data.Map.Strict qualified as Map hiding ((\\)) import Data.Misc (PlainTextPassword6) import Data.Qualified +import Data.Set ((\\)) import Data.Set qualified as Set import Data.Text.Encoding qualified as T import Data.Text.Encoding.Error -import Imports +import Imports hiding ((\\)) import Network.HTTP.Types.Method (StdMethod) import Network.Wai.Utilities import Polysemy @@ -201,8 +204,8 @@ addClientWithReAuthPolicy policy luid@(tUnqualified -> u) con new = do >>= maybe (throwE (ClientUserNotFound u)) pure verifyCode (newClientVerificationCode new) luid maxPermClients <- fromMaybe Opt.defUserMaxPermClients <$> asks (.settings.userMaxPermClients) - let caps :: Maybe ClientCapabilityList - caps = updlhdev $ newClientCapabilities new + let mCaps :: Maybe ClientCapabilityList + mCaps = updlhdev $ newClientCapabilities new where updlhdev :: Maybe ClientCapabilityList -> Maybe ClientCapabilityList updlhdev = @@ -211,12 +214,14 @@ addClientWithReAuthPolicy policy luid@(tUnqualified -> u) con new = do else id lhcaps = ClientSupportsLegalholdImplicitConsent (clt0, old, count) <- - Data.addClientWithReAuthPolicy policy luid clientId' new maxPermClients caps + (Data.addClientWithReAuthPolicy policy luid clientId' new maxPermClients mCaps) !>> ClientDataError let clt = clt0 {clientMLSPublicKeys = newClientMLSPublicKeys new} + when (ClientSupportsConsumableNotifications `Set.member` (foldMap fromClientCapabilityList mCaps)) $ lift $ liftSem $ do + setupConsumableNotifications u clt.clientId lift $ do for_ old $ execDelete u con - liftSem $ GalleyAPIAccess.newClient u (clientId clt) + liftSem $ GalleyAPIAccess.newClient u clt.clientId liftSem $ Intra.onClientEvent u con (ClientAdded clt) when (clientType clt == LegalHoldClientType) $ liftSem $ Events.generateUserEvent u con (UserLegalHoldEnabled u) when (count > 1) $ @@ -239,17 +244,32 @@ addClientWithReAuthPolicy policy luid@(tUnqualified -> u) con new = do VerificationCodeNoPendingCode -> throwE ClientCodeAuthenticationFailed VerificationCodeNoEmail -> throwE ClientCodeAuthenticationFailed -updateClient :: (MonadClient m) => UserId -> ClientId -> UpdateClient -> ExceptT ClientError m () -updateClient u c r = do - client <- lift (Data.lookupClient u c) >>= maybe (throwE ClientNotFound) pure - for_ (updateClientLabel r) $ lift . Data.updateClientLabel u c . Just - for_ (updateClientCapabilities r) $ \caps' -> do - if client.clientCapabilities.fromClientCapabilityList `Set.isSubsetOf` caps'.fromClientCapabilityList - then lift . Data.updateClientCapabilities u c . Just $ caps' - else throwE ClientCapabilitiesCannotBeRemoved - let lk = maybeToList (unpackLastPrekey <$> updateClientLastKey r) - Data.updatePrekeys u c (lk ++ updateClientPrekeys r) !>> ClientDataError - Data.addMLSPublicKeys u c (Map.assocs (updateClientMLSPublicKeys r)) !>> ClientDataError +updateClient :: + (Member NotificationSubsystem r) => + UserId -> + ClientId -> + UpdateClient -> + (Handler r) () +updateClient uid cid req = do + client <- wrapClientE (lift (Data.lookupClient uid cid) >>= maybe (throwE ClientNotFound) pure) !>> clientError + wrapClientE $ for_ req.updateClientLabel $ lift . Data.updateClientLabel uid cid . Just + for_ req.updateClientCapabilities $ \caps -> do + if client.clientCapabilities.fromClientCapabilityList `Set.isSubsetOf` caps.fromClientCapabilityList + then do + -- first set up the notification queues then save the data is more robust than the other way around + let addedCapabilities = caps.fromClientCapabilityList \\ client.clientCapabilities.fromClientCapabilityList + when (ClientSupportsConsumableNotifications `Set.member` addedCapabilities) $ lift $ liftSem $ do + setupConsumableNotifications uid cid + wrapClientE $ lift . Data.updateClientCapabilities uid cid . Just $ caps + else throwE $ clientError ClientCapabilitiesCannotBeRemoved + let lk = maybeToList (unpackLastPrekey <$> req.updateClientLastKey) + wrapClientE + ( do + Data.updatePrekeys uid cid (lk ++ req.updateClientPrekeys) + Data.addMLSPublicKeys uid cid (Map.assocs req.updateClientMLSPublicKeys) + ) + !>> ClientDataError + !>> clientError -- nb. We must ensure that the set of clients known to brig is always -- a superset of the clients known to galley. diff --git a/services/brig/src/Brig/API/Error.hs b/services/brig/src/Brig/API/Error.hs index 019e3786c1b..bde50f54b41 100644 --- a/services/brig/src/Brig/API/Error.hs +++ b/services/brig/src/Brig/API/Error.hs @@ -76,12 +76,6 @@ sendActCodeError (InvalidRecipient _) = StdError $ errorToWai @'E.InvalidEmail sendActCodeError (UserKeyInUse _) = StdError (errorToWai @'E.UserKeyExists) sendActCodeError (ActivationBlacklistedUserKey _) = StdError blacklistedEmail -changeEmailError :: ChangeEmailError -> HttpError -changeEmailError (InvalidNewEmail _ _) = StdError (errorToWai @'E.InvalidEmail) -changeEmailError (EmailExists _) = StdError (errorToWai @'E.UserKeyExists) -changeEmailError (ChangeBlacklistedEmail _) = StdError blacklistedEmail -changeEmailError EmailManagedByScim = StdError $ propertyManagedByScim "email" - legalHoldLoginError :: LegalHoldLoginError -> HttpError legalHoldLoginError LegalHoldLoginNoBindingTeam = StdError noBindingTeam legalHoldLoginError LegalHoldLoginLegalHoldNotEnabled = StdError legalHoldNotEnabled @@ -309,9 +303,6 @@ insufficientTeamPermissions = errorToWai @'E.InsufficientTeamPermissions noBindingTeam :: Wai.Error noBindingTeam = Wai.mkError status403 "no-binding-team" "Operation allowed only on binding teams" -propertyManagedByScim :: LText -> Wai.Error -propertyManagedByScim prop = Wai.mkError status403 "managed-by-scim" $ "Updating \"" <> prop <> "\" is not allowed, because it is managed by SCIM" - sameBindingTeamUsers :: Wai.Error sameBindingTeamUsers = Wai.mkError status403 "same-binding-team-users" "Operation not allowed to binding team users." diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index ea9c2f26f20..75a80dde93e 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -117,6 +117,7 @@ import Wire.UserStore as UserStore import Wire.UserSubsystem import Wire.UserSubsystem qualified as UserSubsystem import Wire.UserSubsystem.Error +import Wire.UserSubsystem.UserSubsystemConfig import Wire.VerificationCode import Wire.VerificationCodeGen import Wire.VerificationCodeSubsystem @@ -149,7 +150,8 @@ servantSitemap :: Member (Polysemy.Error UserSubsystemError) r, Member HashPassword r, Member (Embed IO) r, - Member ActivationCodeStore r + Member ActivationCodeStore r, + Member (Input UserSubsystemConfig) r ) => ServerT BrigIRoutes.API (Handler r) servantSitemap = @@ -203,7 +205,9 @@ accountAPI :: Member HashPassword r, Member InvitationStore r, Member (Embed IO) r, - Member ActivationCodeStore r + Member ActivationCodeStore r, + Member (Polysemy.Error UserSubsystemError) r, + Member (Input UserSubsystemConfig) r ) => ServerT BrigIRoutes.AccountAPI (Handler r) accountAPI = @@ -477,7 +481,8 @@ createUserNoVerify :: Member UserSubsystem r, Member (Input (Local ())) r, Member HashPassword r, - Member PasswordResetCodeStore r + Member PasswordResetCodeStore r, + Member ActivationCodeStore r ) => NewUser -> (Handler r) (Either RegisterError SelfProfile) @@ -538,7 +543,11 @@ changeSelfEmailMaybeSendH :: ( Member BlockListStore r, Member UserKeyStore r, Member EmailSubsystem r, - Member UserSubsystem r + Member UserSubsystem r, + Member UserStore r, + Member ActivationCodeStore r, + Member (Polysemy.Error UserSubsystemError) r, + Member (Input UserSubsystemConfig) r ) => UserId -> EmailUpdate -> @@ -554,7 +563,11 @@ changeSelfEmailMaybeSend :: ( Member BlockListStore r, Member UserKeyStore r, Member EmailSubsystem r, - Member UserSubsystem r + Member UserSubsystem r, + Member UserStore r, + Member ActivationCodeStore r, + Member (Polysemy.Error UserSubsystemError) r, + Member (Input UserSubsystemConfig) r ) => UserId -> MaybeSendEmail -> @@ -562,11 +575,16 @@ changeSelfEmailMaybeSend :: UpdateOriginType -> (Handler r) ChangeEmailResponse changeSelfEmailMaybeSend u ActuallySendEmail email allowScim = do - API.changeSelfEmail u email allowScim + lusr <- qualifyLocal u + lift . liftSem $ + UserSubsystem.requestEmailChange lusr email allowScim changeSelfEmailMaybeSend u DoNotSendEmail email allowScim = do - API.changeEmail u email allowScim !>> changeEmailError >>= \case - ChangeEmailIdempotent -> pure ChangeEmailResponseIdempotent - ChangeEmailNeedsActivation _ -> pure ChangeEmailResponseNeedsActivation + lusr <- qualifyLocal u + (lift . liftSem) + (UserSubsystem.createEmailChangeToken lusr email allowScim) + >>= \case + ChangeEmailIdempotent -> pure ChangeEmailResponseIdempotent + ChangeEmailNeedsActivation _ -> pure ChangeEmailResponseNeedsActivation -- Historically, this end-point was two end-points with distinct matching routes -- (distinguished by query params), and it was only allowed to pass one param per call. This diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 6580a413959..5a5171ab9fc 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -173,9 +173,10 @@ import Wire.UserKeyStore import Wire.UserSearch.Types import Wire.UserStore (UserStore) import Wire.UserStore qualified as UserStore -import Wire.UserSubsystem hiding (checkHandle, checkHandles) +import Wire.UserSubsystem hiding (checkHandle, checkHandles, requestEmailChange) import Wire.UserSubsystem qualified as User import Wire.UserSubsystem.Error +import Wire.UserSubsystem.UserSubsystemConfig import Wire.VerificationCode import Wire.VerificationCodeGen import Wire.VerificationCodeSubsystem @@ -208,22 +209,23 @@ internalEndpointsSwaggerDocsAPIs = -- -- Dual to `internalEndpointsSwaggerDocsAPI`. versionedSwaggerDocsAPI :: Servant.Server VersionedSwaggerDocsAPI -versionedSwaggerDocsAPI (Just (VersionNumber V7)) = +versionedSwaggerDocsAPI (Just (VersionNumber V8)) = swaggerSchemaUIServer $ - ( serviceSwagger @VersionAPITag @'V7 - <> serviceSwagger @BrigAPITag @'V7 - <> serviceSwagger @GalleyAPITag @'V7 - <> serviceSwagger @SparAPITag @'V7 - <> serviceSwagger @CargoholdAPITag @'V7 - <> serviceSwagger @CannonAPITag @'V7 - <> serviceSwagger @GundeckAPITag @'V7 - <> serviceSwagger @ProxyAPITag @'V7 - <> serviceSwagger @OAuthAPITag @'V7 + ( serviceSwagger @VersionAPITag @'V8 + <> serviceSwagger @BrigAPITag @'V8 + <> serviceSwagger @GalleyAPITag @'V8 + <> serviceSwagger @SparAPITag @'V8 + <> serviceSwagger @CargoholdAPITag @'V8 + <> serviceSwagger @CannonAPITag @'V8 + <> serviceSwagger @GundeckAPITag @'V8 + <> serviceSwagger @ProxyAPITag @'V8 + <> serviceSwagger @OAuthAPITag @'V8 ) & S.info . S.title .~ "Wire-Server API" & S.info . S.description ?~ $(embedText =<< makeRelativeToProject "docs/swagger.md") - & S.servers .~ [S.Server ("/" <> toUrlPiece V7) Nothing mempty] + & S.servers .~ [S.Server ("/" <> toUrlPiece V8) Nothing mempty] & cleanupSwagger +versionedSwaggerDocsAPI (Just (VersionNumber V7)) = swaggerPregenUIServer $(pregenSwagger V7) versionedSwaggerDocsAPI (Just (VersionNumber V6)) = swaggerPregenUIServer $(pregenSwagger V6) versionedSwaggerDocsAPI (Just (VersionNumber V5)) = swaggerPregenUIServer $(pregenSwagger V5) versionedSwaggerDocsAPI (Just (VersionNumber V4)) = swaggerPregenUIServer $(pregenSwagger V4) @@ -361,9 +363,10 @@ servantSitemap :: Member VerificationCodeSubsystem r, Member (Concurrency 'Unsafe) r, Member BlockListStore r, - Member (ConnectionStore InternalPaging) r, Member IndexedUserStore r, - Member HashPassword r + Member (ConnectionStore InternalPaging) r, + Member HashPassword r, + Member (Input UserSubsystemConfig) r ) => ServerT BrigAPI (Handler r) servantSitemap = @@ -450,14 +453,21 @@ servantSitemap = userClientAPI :: ServerT UserClientAPI (Handler r) userClientAPI = - Named @"add-client-v6" addClient + Named @"add-client@v6" addClient + :<|> Named @"add-client@v7" addClient :<|> Named @"add-client" addClient - :<|> Named @"update-client" updateClient + :<|> Named @"update-client@v6" API.updateClient + :<|> Named @"update-client@v7" API.updateClient + :<|> Named @"update-client" API.updateClient :<|> Named @"delete-client" deleteClient - :<|> Named @"list-clients-v6" listClients + :<|> Named @"list-clients@v6" listClients + :<|> Named @"list-clients@v7" listClients :<|> Named @"list-clients" listClients - :<|> Named @"get-client-v6" getClient + :<|> Named @"get-client@v6" getClient + :<|> Named @"get-client@v7" getClient :<|> Named @"get-client" getClient + :<|> Named @"get-client-capabilities@v6" getClientCapabilities + :<|> Named @"get-client-capabilities@v7" getClientCapabilities :<|> Named @"get-client-capabilities" getClientCapabilities :<|> Named @"get-client-prekeys" getClientPrekeys :<|> Named @"head-nonce" newNonce @@ -511,7 +521,7 @@ servantSitemap = :<|> Named @"send-login-code" sendLoginCode :<|> Named @"login" login :<|> Named @"logout" logoutH - :<|> Named @"change-self-email" changeSelfEmailH + :<|> Named @"change-self-email" changeSelfEmail :<|> Named @"list-cookies" listCookies :<|> Named @"remove-cookies" removeCookies @@ -678,9 +688,6 @@ deleteClient :: deleteClient usr con clt body = API.rmClient usr con clt (Public.rmPassword body) !>> clientError -updateClient :: UserId -> ClientId -> Public.UpdateClient -> (Handler r) () -updateClient usr clt upd = wrapClientE (API.updateClient usr clt upd) !>> clientError - listClients :: UserId -> (Handler r) [Public.Client] listClients zusr = lift $ API.lookupLocalClients zusr @@ -788,7 +795,8 @@ upgradePersonalToTeam :: Member NotificationSubsystem r, Member TinyLog r, Member UserSubsystem r, - Member UserStore r + Member UserStore r, + Member EmailSending r ) => Local UserId -> Public.BindingNewTeamUser -> @@ -811,7 +819,8 @@ createUser :: Member UserSubsystem r, Member PasswordResetCodeStore r, Member HashPassword r, - Member EmailSending r + Member EmailSending r, + Member ActivationCodeStore r ) => Public.NewUserPublic -> Handler r (Either Public.RegisterError Public.RegisterSuccess) @@ -1024,13 +1033,18 @@ removePhone :: UserId -> Handler r (Maybe Public.RemoveIdentityError) removePhone _ = (lift . pure) Nothing removeEmail :: - ( Member UserKeyStore r, - Member UserSubsystem r, - Member Events r + ( Member UserSubsystem r, + Member (Error UserSubsystemError) r ) => - UserId -> + Local UserId -> Handler r (Maybe Public.RemoveIdentityError) -removeEmail self = lift . exceptTToMaybe $ API.removeEmail self +removeEmail = lift . liftSem . User.removeEmailEither >=> reint + where + reint = \case + Left UserSubsystemNoIdentity -> pure . Just $ Public.NoIdentity + Left UserSubsystemLastIdentity -> pure . Just $ Public.LastIdentity + Left e -> lift . liftSem . throw $ e + Right () -> pure Nothing checkPasswordExists :: (Member PasswordStore r) => UserId -> (Handler r) Bool checkPasswordExists = fmap isJust . lift . liftSem . lookupHashedPassword @@ -1101,7 +1115,12 @@ getHandleInfoUnqualifiedH self handle = do Public.UserHandleInfo . Public.profileQualifiedId <$$> Handle.getHandleInfo self (Qualified handle domain) -changeHandle :: (Member UserSubsystem r) => Local UserId -> ConnId -> Public.HandleUpdate -> Handler r () +changeHandle :: + (Member UserSubsystem r) => + Local UserId -> + ConnId -> + Public.HandleUpdate -> + Handler r () changeHandle u conn (Public.HandleUpdate h) = lift $ liftSem do User.updateHandle u (Just conn) UpdateOriginWireClient h @@ -1336,7 +1355,11 @@ updateUserEmail :: Member UserKeyStore r, Member GalleyAPIAccess r, Member EmailSubsystem r, - Member UserSubsystem r + Member UserSubsystem r, + Member UserStore r, + Member ActivationCodeStore r, + Member (Error UserSubsystemError) r, + Member (Input UserSubsystemConfig) r ) => UserId -> UserId -> @@ -1347,7 +1370,9 @@ updateUserEmail zuserId emailOwnerId (Public.EmailUpdate email) = do whenM (not <$> assertHasPerm maybeZuserTeamId) $ throwStd insufficientTeamPermissions maybeEmailOwnerTeamId <- lift $ wrapClient $ Data.lookupUserTeam emailOwnerId checkSameTeam maybeZuserTeamId maybeEmailOwnerTeamId - void $ API.changeSelfEmail emailOwnerId email UpdateOriginWireClient + lEmailOwnerId <- qualifyLocal emailOwnerId + void . lift . liftSem $ + User.requestEmailChange lEmailOwnerId email UpdateOriginWireClient where checkSameTeam :: Maybe TeamId -> Maybe TeamId -> (Handler r) () checkSameTeam (Just zuserTeamId) maybeEmailOwnerTeamId = diff --git a/services/brig/src/Brig/API/Types.hs b/services/brig/src/Brig/API/Types.hs index 5da615a530f..1936a91c644 100644 --- a/services/brig/src/Brig/API/Types.hs +++ b/services/brig/src/Brig/API/Types.hs @@ -30,7 +30,7 @@ module Brig.API.Types ) where -import Brig.Data.Activation (Activation (..), ActivationError (..)) +import Brig.Data.Activation (ActivationError (..)) import Brig.Data.Client (ClientDataError (..)) import Brig.Types.Intra import Data.Code @@ -42,6 +42,7 @@ import Imports import Network.Wai.Utilities.Error qualified as Wai import Wire.API.Federation.Error import Wire.API.User +import Wire.API.User.Activation import Wire.AuthenticationSubsystem.Error import Wire.UserKeyStore @@ -65,14 +66,6 @@ data ActivationResult ActivationPass deriving (Show) --- | Outcome of the invariants check in 'Brig.API.User.changeEmail'. -data ChangeEmailResult - = -- | The request was successful, user needs to verify the new email address - ChangeEmailNeedsActivation !(User, Activation, EmailAddress) - | -- | The user asked to change the email address to the one already owned - ChangeEmailIdempotent - deriving (Show) - ------------------------------------------------------------------------------- -- Failures @@ -153,12 +146,6 @@ data VerificationCodeError | VerificationCodeNoPendingCode | VerificationCodeNoEmail -data ChangeEmailError - = InvalidNewEmail !EmailAddress !String - | EmailExists !EmailAddress - | ChangeBlacklistedEmail !EmailAddress - | EmailManagedByScim - data SendActivationCodeError = InvalidRecipient EmailKey | UserKeyInUse EmailKey diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index 46c3e658e30..92d260f6d2f 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -25,8 +25,6 @@ module Brig.API.User createUserSpar, createUserInviteViaScim, checkRestrictedUserCreation, - changeSelfEmail, - changeEmail, CheckHandleResp (..), checkHandle, lookupHandle, @@ -36,7 +34,6 @@ module Brig.API.User Data.lookupName, Data.lookupUser, Data.lookupRichInfoMultiUsers, - removeEmail, revokeIdentity, deleteUserNoVerify, deleteUsersNoVerify, @@ -67,7 +64,6 @@ module Brig.API.User ) where -import Brig.API.Error qualified as Error import Brig.API.Types import Brig.API.Util import Brig.App as App @@ -128,11 +124,12 @@ import Wire.API.User.Activation import Wire.API.User.Client import Wire.API.User.RichInfo import Wire.API.UserEvent -import Wire.ActivationCodeStore (ActivationCodeStore) +import Wire.ActivationCodeStore import Wire.ActivationCodeStore qualified as ActivationCode import Wire.AuthenticationSubsystem (AuthenticationSubsystem, internalLookupPasswordResetCode) import Wire.BlockListStore as BlockListStore import Wire.DeleteQueue +import Wire.EmailSending import Wire.EmailSubsystem import Wire.Error import Wire.Events (Events) @@ -266,7 +263,8 @@ upgradePersonalToTeam :: Member NotificationSubsystem r, Member (Input (Local ())) r, Member (Input UTCTime) r, - Member (ConnectionStore InternalPaging) r + Member (ConnectionStore InternalPaging) r, + Member EmailSending r ) => Local UserId -> BindingNewTeamUser -> @@ -293,12 +291,13 @@ upgradePersonalToTeam luid bNewTeam = do pure $ CreateUserTeam tid (fromRange newTeam.newTeamName) liftSem $ updateUserTeam uid tid + liftSem $ User.internalUpdateSearchIndex uid liftSem $ Intra.sendUserEvent uid Nothing (teamUpdated uid tid) initAccountFeatureConfig uid -- send confirmation email for_ (userEmail user) $ \email -> do - sendPersonalUserCreatorWelcomeMail + sendNewTeamOwnerWelcomeEmail email tid bNewTeam.bnuTeam.newTeamName.fromRange @@ -319,7 +318,8 @@ createUser :: Member (Input (Local ())) r, Member PasswordResetCodeStore r, Member HashPassword r, - Member InvitationStore r + Member InvitationStore r, + Member ActivationCodeStore r ) => NewUser -> ExceptT RegisterError (AppT r) CreateUserResult @@ -479,17 +479,22 @@ createUser new = do pure $ CreateUserTeam tid nm -- Handle e-mail activation (deprecated, see #RefRegistrationNoPreverification in /docs/reference/user/registration.md) - handleEmailActivation :: Maybe EmailAddress -> UserId -> Maybe BindingNewTeamUser -> ExceptT RegisterError (AppT r) (Maybe Activation) + handleEmailActivation :: + Maybe EmailAddress -> + UserId -> + Maybe BindingNewTeamUser -> + ExceptT RegisterError (AppT r) (Maybe Activation) handleEmailActivation email uid newTeam = do fmap join . for (mkEmailKey <$> email) $ \ek -> case newUserEmailCode new of Nothing -> do timeout <- asks (.settings.activationTimeout) - edata <- lift . wrapClient $ Data.newActivation ek timeout (Just uid) - lift . liftSem . Log.info $ - field "user" (toByteString uid) - . field "activation.key" (toByteString $ activationKey edata) - . msg (val "Created email activation key/code pair") - pure $ Just edata + lift . liftSem $ do + edata <- newActivationCode ek timeout (Just uid) + Log.info $ + field "user" (toByteString uid) + . field "activation.key" (toByteString $ activationKey edata) + . msg (val "Created email activation key/code pair") + pure $ Just edata Just c -> do ak <- liftIO $ Data.mkActivationKey ek void $ @@ -546,82 +551,6 @@ checkRestrictedUserCreation new = do ) $ throwE RegisterErrorUserCreationRestricted -------------------------------------------------------------------------------- --- Change Email - --- | Call 'changeEmail' and process result: if email changes to itself, succeed, if not, send --- validation email. -changeSelfEmail :: - ( Member BlockListStore r, - Member UserKeyStore r, - Member EmailSubsystem r, - Member UserSubsystem r - ) => - UserId -> - EmailAddress -> - UpdateOriginType -> - ExceptT HttpError (AppT r) ChangeEmailResponse -changeSelfEmail u email allowScim = do - changeEmail u email allowScim !>> Error.changeEmailError >>= \case - ChangeEmailIdempotent -> - pure ChangeEmailResponseIdempotent - ChangeEmailNeedsActivation (usr, adata, en) -> lift $ do - liftSem $ sendOutEmail usr adata en - wrapClient $ Data.updateEmailUnvalidated u email - liftSem $ User.internalUpdateSearchIndex u - pure ChangeEmailResponseNeedsActivation - where - sendOutEmail usr adata en = do - (maybe sendActivationMail (const sendEmailAddressUpdateMail) usr.userIdentity) - en - (userDisplayName usr) - (activationKey adata) - (activationCode adata) - (Just (userLocale usr)) - --- | Prepare changing the email (checking a number of invariants). -changeEmail :: (Member BlockListStore r, Member UserKeyStore r) => UserId -> EmailAddress -> UpdateOriginType -> ExceptT ChangeEmailError (AppT r) ChangeEmailResult -changeEmail u email updateOrigin = do - let ek = mkEmailKey email - blacklisted <- lift . liftSem $ BlockListStore.exists ek - when blacklisted $ - throwE (ChangeBlacklistedEmail email) - available <- lift $ liftSem $ keyAvailable ek (Just u) - unless available $ - throwE $ - EmailExists email - usr <- maybe (throwM $ UserProfileNotFound u) pure =<< lift (wrapClient $ Data.lookupUser WithPendingInvitations u) - case emailIdentity =<< userIdentity usr of - -- The user already has an email address and the new one is exactly the same - Just current | current == email -> pure ChangeEmailIdempotent - _ -> do - unless (userManagedBy usr /= ManagedByScim || updateOrigin == UpdateOriginScim) $ - throwE EmailManagedByScim - timeout <- asks (.settings.activationTimeout) - act <- lift . wrapClient $ Data.newActivation ek timeout (Just u) - pure $ ChangeEmailNeedsActivation (usr, act, email) - -------------------------------------------------------------------------------- --- Remove Email - -removeEmail :: - ( Member UserKeyStore r, - Member UserSubsystem r, - Member Events r - ) => - UserId -> - ExceptT RemoveIdentityError (AppT r) () -removeEmail uid = do - ident <- lift $ fetchUserIdentity uid - case ident of - Just (SSOIdentity (UserSSOId _) (Just e)) -> lift $ do - liftSem $ deleteKey $ mkEmailKey e - wrapClient $ Data.deleteEmail uid - liftSem $ Events.generateUserEvent uid Nothing (emailRemoved uid e) - liftSem $ User.internalUpdateSearchIndex uid - Just _ -> throwE LastIdentity - Nothing -> throwE NoIdentity - ------------------------------------------------------------------------------- -- Forcefully revoke a verified identity @@ -720,7 +649,6 @@ activateWithCurrency :: -- | The user for whom to activate the key. Maybe UserId -> -- | Potential currency update. - -- ^ TODO: to be removed once billing supports currency changes after team creation Maybe Currency.Alpha -> ExceptT ActivationError (AppT r) ActivationResult activateWithCurrency tgt code usr cur = do @@ -776,6 +704,7 @@ onActivated (EmailActivated uid email) = do -- docs/reference/user/activation.md {#RefActivationRequest} sendActivationCode :: + forall r. ( Member BlockListStore r, Member EmailSubsystem r, Member GalleyAPIAccess r, @@ -801,22 +730,27 @@ sendActivationCode email loc = do Just (Just uid, c) -> sendActivationEmail ek c uid -- User re-requesting activation where notFound = throwM . UserDisplayNameNotFound + mkPair :: + EmailKey -> + Maybe ActivationCode -> + Maybe UserId -> + ExceptT SendActivationCodeError (AppT r) (ActivationKey, ActivationCode) mkPair k c u = do timeout <- asks (.settings.activationTimeout) case c of Just c' -> liftIO $ (,c') <$> Data.mkActivationKey k - Nothing -> lift $ do - dat <- Data.newActivation k timeout u + Nothing -> lift . liftSem $ do + dat <- newActivationCode k timeout u pure (activationKey dat, activationCode dat) sendVerificationEmail ek uc = do - (key, code) <- wrapClientE $ mkPair ek uc Nothing + (key, code) <- mkPair ek uc Nothing let em = emailKeyOrig ek lift $ liftSem $ sendVerificationMail em key code loc sendActivationEmail ek uc uid = do -- FUTUREWORK(fisx): we allow for 'PendingInvitations' here, but I'm not sure this -- top-level function isn't another piece of a deprecated onboarding flow? u <- maybe (notFound uid) pure =<< lift (wrapClient $ Data.lookupUser WithPendingInvitations uid) - (aKey, aCode) <- wrapClientE $ mkPair ek (Just uc) (Just uid) + (aKey, aCode) <- mkPair ek (Just uc) (Just uid) let ident = userIdentity u name = userDisplayName u loc' = loc <|> Just (userLocale u) diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index 5248effd92b..7b2ceeb4b37 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -114,6 +114,7 @@ type BrigLowerLevelEffects = PropertySubsystem, DeleteQueue, Wire.Events.Events, + NotificationSubsystem, Error UserSubsystemError, Error TeamInvitationSubsystemError, Error AuthenticationSubsystemError, @@ -140,7 +141,6 @@ type BrigLowerLevelEffects = Input (Local ()), Input (Maybe AllowlistEmailDomains), Input TeamTemplates, - NotificationSubsystem, GundeckAPIAccess, FederationConfigStore, Jwk, @@ -176,7 +176,8 @@ runBrigToIO e (AppT ma) = do { emailVisibilityConfig = e.settings.emailVisibility, defaultLocale = Opt.defaultUserLocale e.settings, searchSameTeamOnly = fromMaybe False e.settings.searchSameTeamOnly, - maxTeamSize = e.settings.maxTeamSize + maxTeamSize = e.settings.maxTeamSize, + activationCodeTimeout = e.settings.activationTimeout } teamInvitationSubsystemConfig = TeamInvitationSubsystemConfig @@ -213,6 +214,9 @@ runBrigToIO e (AppT ma) = do } -- These interpreters depend on each other, we use let recursion to solve that. + -- + -- This terminates if and only if we do not create an action sequence at + -- runtime such that interpretation of actions results in a call cycle. userSubsystemInterpreter :: (Members BrigLowerLevelEffects r) => InterpreterFor UserSubsystem r userSubsystemInterpreter = runUserSubsystem authSubsystemInterpreter @@ -246,7 +250,6 @@ runBrigToIO e (AppT ma) = do . interpretJwk . interpretFederationDomainConfig e.casClient e.settings.federationStrategy (foldMap (remotesMapFromCfgFile . fmap (.federationDomainConfig)) e.settings.federationDomainConfigs) . runGundeckAPIAccess e.gundeckEndpoint - . runNotificationSubsystemGundeck (defaultNotificationSubsystemConfig e.requestId) . runInputConst (teamTemplatesNoLocale e) . runInputConst e.settings.allowlistEmailDomains . runInputConst (toLocalUnsafe e.settings.federationDomain ()) @@ -273,6 +276,7 @@ runBrigToIO e (AppT ma) = do . mapError authenticationSubsystemErrorToHttpError . mapError teamInvitationErrorToHttpError . mapError userSubsystemErrorToHttpError + . runNotificationSubsystemGundeck (defaultNotificationSubsystemConfig e.requestId) . runEvents . runDeleteQueue e.internalEvents . interpretPropertySubsystem propertySubsystemConfig diff --git a/services/brig/src/Brig/Data/Activation.hs b/services/brig/src/Brig/Data/Activation.hs index ae9ce48899f..981038f9d42 100644 --- a/services/brig/src/Brig/Data/Activation.hs +++ b/services/brig/src/Brig/Data/Activation.hs @@ -17,11 +17,9 @@ -- | Activation of 'Email' addresses and 'Phone' numbers. module Brig.Data.Activation - ( Activation (..), - ActivationEvent (..), + ( ActivationEvent (..), ActivationError (..), activationErrorToRegisterError, - newActivation, mkActivationKey, activateKey, verifyCode, @@ -34,16 +32,12 @@ import Brig.Types.Intra import Cassandra import Control.Error import Data.Id -import Data.Text (pack) import Data.Text.Ascii qualified as Ascii import Data.Text.Encoding qualified as T import Data.Text.Lazy qualified as LT import Imports -import OpenSSL.BN (randIntegerZeroToNMinusOne) import OpenSSL.EVP.Digest (digestBS, getDigestByName) import Polysemy -import Text.Printf (printf) -import Util.Timeout import Wire.API.User import Wire.API.User.Activation import Wire.API.User.Password @@ -53,15 +47,6 @@ import Wire.UserKeyStore import Wire.UserSubsystem (UserSubsystem) import Wire.UserSubsystem qualified as User --- | The information associated with the pending activation of a 'UserKey'. -data Activation = Activation - { -- | An opaque key for the original 'UserKey' pending activation. - activationKey :: !ActivationKey, - -- | The confidential activation code. - activationCode :: !ActivationCode - } - deriving (Eq, Show) - data ActivationError = UserKeyExists !LT.Text | InvalidActivationCodeWrongUser @@ -82,10 +67,6 @@ data ActivationEvent | EmailActivated !UserId !EmailAddress deriving (Show) --- | Max. number of activation attempts per 'ActivationKey'. -maxAttempts :: Int32 -maxAttempts = 3 - -- docs/reference/user/activation.md {#RefActivationSubmit} activateKey :: forall r. @@ -151,29 +132,6 @@ activateKey k c u = wrapClientE (verifyCode k c) >>= pickUser >>= activate throwE . UserKeyExists . LT.fromStrict $ fromEmail (emailKeyOrig key) --- | Create a new pending activation for a given 'EmailKey'. -newActivation :: - (MonadClient m) => - EmailKey -> - -- | The timeout for the activation code. - Timeout -> - -- | The user with whom to associate the activation code. - Maybe UserId -> - m Activation -newActivation uk timeout u = do - let typ = "email" - key = fromEmail (emailKeyOrig uk) - code <- liftIO $ genCode - insert typ key code - where - insert t k c = do - key <- liftIO $ mkActivationKey uk - retry x5 . write keyInsert $ params LocalQuorum (key, t, k, c, u, maxAttempts, round timeout) - pure $ Activation key c - genCode = - ActivationCode . Ascii.unsafeFromText . pack . printf "%06d" - <$> randIntegerZeroToNMinusOne 1000000 - -- | Verify an activation code. verifyCode :: (MonadClient m) => @@ -196,6 +154,11 @@ verifyCode key code = do mkScope _ _ _ = throwE invalidCode countdown = lift . retry x5 . write keyInsert . params LocalQuorum revoke = lift $ deleteActivationPair key + keyInsert :: PrepQuery W (ActivationKey, Text, Text, ActivationCode, Maybe UserId, Int32, Int32) () + keyInsert = + "INSERT INTO activation_keys \ + \(key, key_type, key_text, code, user, retries) VALUES \ + \(? , ? , ? , ? , ? , ? ) USING TTL ?" mkActivationKey :: EmailKey -> IO ActivationKey mkActivationKey k = do @@ -213,12 +176,6 @@ invalidUser = InvalidActivationCodeWrongUser -- "User does not exist." invalidCode :: ActivationError invalidCode = InvalidActivationCodeWrongCode -- "Invalid activation code" -keyInsert :: PrepQuery W (ActivationKey, Text, Text, ActivationCode, Maybe UserId, Int32, Int32) () -keyInsert = - "INSERT INTO activation_keys \ - \(key, key_type, key_text, code, user, retries) VALUES \ - \(? , ? , ? , ? , ? , ? ) USING TTL ?" - keySelect :: PrepQuery R (Identity ActivationKey) (Int32, Ascii, Text, ActivationCode, Maybe UserId, Int32) keySelect = "SELECT ttl(code) as ttl, key_type, key_text, code, user, retries FROM activation_keys WHERE key = ?" diff --git a/services/brig/src/Brig/Data/User.hs b/services/brig/src/Brig/Data/User.hs index 7e1f8e57656..326ef1cb780 100644 --- a/services/brig/src/Brig/Data/User.hs +++ b/services/brig/src/Brig/Data/User.hs @@ -36,7 +36,6 @@ module Brig.Data.User -- * Updates updateEmail, - updateEmailUnvalidated, updateSSOId, updateManagedBy, activateUser, @@ -46,7 +45,6 @@ module Brig.Data.User updateFeatureConferenceCalling, -- * Deletions - deleteEmail, deleteEmailUnvalidated, deleteServiceUser, ) @@ -209,9 +207,6 @@ insertAccount u mbConv password activated = retry x5 . batch $ do updateEmail :: (MonadClient m) => UserId -> EmailAddress -> m () updateEmail u e = retry x5 $ write userEmailUpdate (params LocalQuorum (e, u)) -updateEmailUnvalidated :: (MonadClient m) => UserId -> EmailAddress -> m () -updateEmailUnvalidated u e = retry x5 $ write userEmailUnvalidatedUpdate (params LocalQuorum (e, u)) - updateSSOId :: (MonadClient m) => UserId -> Maybe UserSSOId -> m Bool updateSSOId u ssoid = do mteamid <- lookupUserTeam u @@ -234,9 +229,6 @@ updateFeatureConferenceCalling uid mStatus = update :: PrepQuery W (Maybe FeatureStatus, UserId) () update = fromString "update user set feature_conference_calling = ? where id = ?" -deleteEmail :: (MonadClient m) => UserId -> m () -deleteEmail u = retry x5 $ write userEmailDelete (params LocalQuorum (Identity u)) - deleteEmailUnvalidated :: (MonadClient m) => UserId -> m () deleteEmailUnvalidated u = retry x5 $ write userEmailUnvalidatedDelete (params LocalQuorum (Identity u)) @@ -435,9 +427,6 @@ userInsert = userEmailUpdate :: PrepQuery W (EmailAddress, UserId) () userEmailUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET email = ? WHERE id = ?" -userEmailUnvalidatedUpdate :: PrepQuery W (EmailAddress, UserId) () -userEmailUnvalidatedUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET email_unvalidated = ? WHERE id = ?" - userEmailUnvalidatedDelete :: PrepQuery W (Identity UserId) () userEmailUnvalidatedDelete = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET email_unvalidated = null WHERE id = ?" @@ -456,9 +445,6 @@ userDeactivatedUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDAT userActivatedUpdate :: PrepQuery W (Maybe EmailAddress, UserId) () userActivatedUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET activated = true, email = ? WHERE id = ?" -userEmailDelete :: PrepQuery W (Identity UserId) () -userEmailDelete = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET email = null, write_time_bumper = 0 WHERE id = ?" - userRichInfoUpdate :: PrepQuery W (RichInfoAssocList, UserId) () userRichInfoUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE rich_info SET json = ? WHERE user = ?" diff --git a/services/brig/src/Brig/Federation/Client.hs b/services/brig/src/Brig/Federation/Client.hs index 9c5302689d2..15fc53f5bdf 100644 --- a/services/brig/src/Brig/Federation/Client.hs +++ b/services/brig/src/Brig/Federation/Client.hs @@ -141,6 +141,7 @@ notifyUserDeleted self remotes = do let remoteConnections = tUnqualified remotes let notif = UserDeletedConnectionsNotification (tUnqualified self) remoteConnections remoteDomain = tDomain remotes + asks (.rabbitmqChannel) >>= \case Just chanVar -> do enqueueNotification (tDomain self) remoteDomain Q.Persistent chanVar $ diff --git a/services/brig/src/Brig/Index/Options.hs b/services/brig/src/Brig/Index/Options.hs index f9f382c0043..a12ca78111c 100644 --- a/services/brig/src/Brig/Index/Options.hs +++ b/services/brig/src/Brig/Index/Options.hs @@ -39,7 +39,7 @@ module Brig.Index.Options commandParser, mkCreateIndexSettings, toESServer, - ReindexFromAnotherIndexSettings, + ReindexFromAnotherIndexSettings (..), reindexDestIndex, reindexTimeoutSeconds, reindexEsConnection, diff --git a/services/brig/src/Brig/Provider/API.hs b/services/brig/src/Brig/Provider/API.hs index 162a1109060..a43d284157b 100644 --- a/services/brig/src/Brig/Provider/API.hs +++ b/services/brig/src/Brig/Provider/API.hs @@ -153,7 +153,8 @@ botAPI = :<|> Named @"bot-delete-self" botDeleteSelf :<|> Named @"bot-list-prekeys" botListPrekeys :<|> Named @"bot-update-prekeys" botUpdatePrekeys - :<|> Named @"bot-get-client-v6" botGetClient + :<|> Named @"bot-get-client@v6" botGetClient + :<|> Named @"bot-get-client@v7" botGetClient :<|> Named @"bot-get-client" botGetClient :<|> Named @"bot-claim-users-prekeys" botClaimUsersPrekeys :<|> Named @"bot-list-users" botListUserProfiles diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs index e6fcd9f0d43..fa24e2b4cf6 100644 --- a/services/brig/src/Brig/Team/API.hs +++ b/services/brig/src/Brig/Team/API.hs @@ -40,10 +40,8 @@ import Data.Id import Data.List1 qualified as List1 import Data.Qualified import Data.Range -import Data.Text.Ascii import Data.Text.Encoding (encodeUtf8) import Data.Text.Lazy qualified as LT -import Data.Text.Lazy qualified as Text import Imports hiding (head) import Network.Wai.Utilities hiding (Error, code, message) import Polysemy @@ -67,9 +65,9 @@ import Wire.API.Team.Invitation qualified as Public import Wire.API.Team.Member (teamMembers) import Wire.API.Team.Member qualified as Teams import Wire.API.Team.Permission (Perm (AddTeamMember)) -import Wire.API.Team.Role import Wire.API.User hiding (fromEmail) import Wire.BlockListStore +import Wire.EmailSubsystem.Interpreter (renderInvitationUrl) import Wire.EmailSubsystem.Template import Wire.Error import Wire.Events (Events) @@ -80,6 +78,7 @@ import Wire.InvitationStore (InvitationStore (..), PaginatedResult (..), StoredI import Wire.InvitationStore qualified as Store import Wire.Sem.Concurrency import Wire.TeamInvitationSubsystem +import Wire.TeamInvitationSubsystem.Interpreter (toInvitation) import Wire.UserKeyStore import Wire.UserSubsystem import Wire.UserSubsystem.Error @@ -97,7 +96,8 @@ servantAPI :: ) => ServerT TeamsAPI (Handler r) servantAPI = - Named @"send-team-invitation" (\luid tid invreq -> lift . liftSem $ inviteUser luid tid invreq) + Named @"send-team-invitation@v6" (\luid tid invreq -> lift . liftSem $ inviteUser luid tid invreq) + :<|> Named @"send-team-invitation" (\luid tid invreq -> lift . liftSem $ inviteUser luid tid invreq) :<|> Named @"get-team-invitations" (\u t inv s -> lift . liftSem $ listInvitations u t inv s) :<|> Named @"get-team-invitation" (\u t inv -> lift . liftSem $ getInvitation u t inv) :<|> Named @"delete-team-invitation" (\u t inv -> lift . liftSem $ deleteInvitation u t inv) @@ -156,7 +156,8 @@ createInvitationViaScim tid newUser@(NewUserScimInvitation _tid _uid@(Id (Id -> { locale = loc, role = Nothing, -- (unused, it's in the type for 'createInvitationV5') inviteeName = Just name, - inviteeEmail = email + inviteeEmail = email, + allowExisting = True } context = @@ -234,54 +235,32 @@ listInvitations uid tid startingId mSize = do -- To create the correct team invitation URL, we need to detect whether the invited account already exists. -- Optimization: if url is not to be shown, do not check for existing personal user. toInvitationHack :: ShowOrHideInvitationUrl -> StoredInvitation -> Sem r Invitation - toInvitationHack HideInvitationUrl si = toInvitation False HideInvitationUrl si -- isPersonalUserMigration is always ignored here + toInvitationHack HideInvitationUrl si = + toInvitation "" HideInvitationUrl si -- isPersonalUserMigration is always ignored here toInvitationHack ShowInvitationUrl si = do isPersonalUserMigration <- isPersonalUser (mkEmailKey si.email) - toInvitation isPersonalUserMigration ShowInvitationUrl si + template <- + if isPersonalUserMigration + then invitationEmailUrl . existingUserInvitationEmail <$> input + else invitationEmailUrl . invitationEmail <$> input + let url = renderInvitationUrl template tid si.code id + toInvitation url ShowInvitationUrl si --- | brig used to not store the role, so for migration we allow this to be empty and fill in the --- default here. -toInvitation :: +mkInviteUrl :: + forall r. ( Member TinyLog r, Member (Input TeamTemplates) r ) => - Bool -> ShowOrHideInvitationUrl -> - StoredInvitation -> - Sem r Invitation -toInvitation isPersonalUserMigration showUrl storedInv = do - url <- - if isPersonalUserMigration - then mkInviteUrlPersonalUser showUrl storedInv.teamId storedInv.code - else mkInviteUrl showUrl storedInv.teamId storedInv.code - pure $ - Invitation - { team = storedInv.teamId, - role = fromMaybe defaultRole storedInv.role, - invitationId = storedInv.invitationId, - createdAt = storedInv.createdAt, - createdBy = storedInv.createdBy, - inviteeEmail = storedInv.email, - inviteeName = storedInv.name, - inviteeUrl = url - } - -getInviteUrl :: - forall r. - (Member TinyLog r) => - InvitationEmailTemplate -> TeamId -> - AsciiText Base64Url -> + InvitationCode -> Sem r (Maybe (URIRef Absolute)) -getInviteUrl (invitationEmailUrl -> template) team code = do - let branding = id -- url is not branded - let url = Text.toStrict $ renderTextWithBranding template replace branding +mkInviteUrl HideInvitationUrl _ _ = pure Nothing +mkInviteUrl ShowInvitationUrl team c = do + template <- invitationEmailUrl . invitationEmail <$> input + let url = renderInvitationUrl template team c id parseHttpsUrl url where - replace "team" = idToText team - replace "code" = toText code - replace x = x - parseHttpsUrl :: Text -> Sem r (Maybe (URIRef Absolute)) parseHttpsUrl url = either (\e -> Nothing <$ logError url e) (pure . Just) $ @@ -293,32 +272,6 @@ getInviteUrl (invitationEmailUrl -> template) team code = do . Log.field "url" url . Log.field "error" (show e) -mkInviteUrl :: - ( Member TinyLog r, - Member (Input TeamTemplates) r - ) => - ShowOrHideInvitationUrl -> - TeamId -> - InvitationCode -> - Sem r (Maybe (URIRef Absolute)) -mkInviteUrl HideInvitationUrl _ _ = pure Nothing -mkInviteUrl ShowInvitationUrl team (InvitationCode c) = do - template <- invitationEmail <$> input - getInviteUrl template team c - -mkInviteUrlPersonalUser :: - ( Member TinyLog r, - Member (Input TeamTemplates) r - ) => - ShowOrHideInvitationUrl -> - TeamId -> - InvitationCode -> - Sem r (Maybe (URIRef Absolute)) -mkInviteUrlPersonalUser HideInvitationUrl _ _ = pure Nothing -mkInviteUrlPersonalUser ShowInvitationUrl team (InvitationCode c) = do - template <- existingUserInvitationEmail <$> input - getInviteUrl template team c - getInvitation :: ( Member GalleyAPIAccess r, Member InvitationStore r, @@ -332,7 +285,6 @@ getInvitation :: Sem r (Maybe Public.Invitation) getInvitation uid tid iid = do ensurePermissions uid tid [AddTeamMember] - invitationM <- Store.lookupInvitation tid iid case invitationM of Nothing -> pure Nothing @@ -350,14 +302,24 @@ isPersonalUser uke = do Just account -> account.userStatus == Active && isNothing account.userTeam getInvitationByCode :: + forall r. ( Member Store.InvitationStore r, - Member (Error UserSubsystemError) r + Member (Error UserSubsystemError) r, + Member UserSubsystem r, + Member (Input (Local ())) r ) => InvitationCode -> - Sem r Public.Invitation + Sem r Public.InvitationUserView getInvitationByCode c = do - inv <- Store.lookupInvitationByCode c - maybe (throw UserSubsystemInvalidInvitationCode) (pure . Store.invitationFromStored Nothing) inv + storedInv <- + Store.lookupInvitationByCode c + >>= note UserSubsystemInvalidInvitationCode + let inv = Store.invitationFromStored Nothing storedInv + mInviterEmail <- + isPersonalUser (mkEmailKey inv.inviteeEmail) >>= \case + False -> pure Nothing + True -> maybe (pure Nothing) (qualifyLocal' >=> getUserEmail) inv.createdBy + pure $ InvitationUserView {invitation = inv, inviterEmail = mInviterEmail} headInvitationByEmail :: (Member InvitationStore r, Member TinyLog r) => diff --git a/services/brig/src/Brig/Team/Email.hs b/services/brig/src/Brig/Team/Email.hs index 441cee5d7bf..e9c22b1f82a 100644 --- a/services/brig/src/Brig/Team/Email.hs +++ b/services/brig/src/Brig/Team/Email.hs @@ -19,8 +19,7 @@ module Brig.Team.Email ( sendMemberWelcomeMail, - sendPersonalUserMemberWelcomeMail, - sendPersonalUserCreatorWelcomeMail, + sendNewTeamOwnerWelcomeEmail, ) where @@ -41,13 +40,11 @@ sendMemberWelcomeMail to tid teamName loc = do branding <- asks (.templateBranding) liftSem $ sendMail $ renderMemberWelcomeMail to tid teamName tpl branding -sendPersonalUserMemberWelcomeMail :: EmailAddress -> TeamId -> Text -> Maybe Locale -> (AppT r) () -sendPersonalUserMemberWelcomeMail _ _ _ _ = do - pure () - -sendPersonalUserCreatorWelcomeMail :: EmailAddress -> TeamId -> Text -> Maybe Locale -> (AppT r) () -sendPersonalUserCreatorWelcomeMail _ _ _ _ = do - pure () +sendNewTeamOwnerWelcomeEmail :: (Member EmailSending r) => EmailAddress -> TeamId -> Text -> Maybe Locale -> (AppT r) () +sendNewTeamOwnerWelcomeEmail to tid teamName loc = do + tpl <- newTeamOwnerWelcomeEmail . snd <$> teamTemplatesWithLocale loc + branding <- asks (.templateBranding) + liftSem $ sendMail $ renderNewTeamOwnerWelcomeEmail to tid teamName tpl branding ------------------------------------------------------------------------------- -- Member Welcome Email @@ -73,3 +70,28 @@ renderMemberWelcomeMail emailTo tid teamName MemberWelcomeEmailTemplate {..} bra replace "team_id" = idToText tid replace "team_name" = teamName replace x = x + +------------------------------------------------------------------------------- +-- New Team Owner Welcome Email + +renderNewTeamOwnerWelcomeEmail :: EmailAddress -> TeamId -> Text -> NewTeamOwnerWelcomeEmailTemplate -> TemplateBranding -> Mail +renderNewTeamOwnerWelcomeEmail emailTo tid teamName NewTeamOwnerWelcomeEmailTemplate {..} branding = + (emptyMail from) + { mailTo = [to], + mailHeaders = + [ ("Subject", toStrict subj), + ("X-Zeta-Purpose", "Welcome") + ], + mailParts = [[plainPart txt, htmlPart html]] + } + where + from = Address (Just newTeamOwnerWelcomeEmailSenderName) (fromEmail newTeamOwnerWelcomeEmailSender) + to = Address Nothing (fromEmail emailTo) + txt = renderTextWithBranding newTeamOwnerWelcomeEmailBodyText replace branding + html = renderHtmlWithBranding newTeamOwnerWelcomeEmailBodyHtml replace branding + subj = renderTextWithBranding newTeamOwnerWelcomeEmailSubject replace branding + replace "url" = newTeamOwnerWelcomeEmailUrl + replace "email" = fromEmail emailTo + replace "team_id" = idToText tid + replace "team_name" = teamName + replace x = x diff --git a/services/brig/src/Brig/Team/Template.hs b/services/brig/src/Brig/Team/Template.hs index 86c409e9f62..713c1555a6f 100644 --- a/services/brig/src/Brig/Team/Template.hs +++ b/services/brig/src/Brig/Team/Template.hs @@ -71,13 +71,12 @@ loadTeamTemplates o = readLocalesDir defLocale (templateDir gOptions) "team" $ \ (emailSender gOptions) <$> readText fp "email/sender.txt" ) - <*> ( PersonalUserCreatorWelcomeEmailTemplate - "" - (template "") - (template "") - (template "") - (emailSender gOptions) - <$> readText fp "email/sender.txt" + <*> ( NewTeamOwnerWelcomeEmailTemplate (tCreatorWelcomeUrl tOptions) + <$> readTemplate fp "email/new-team-owner-welcome-subject.txt" + <*> readTemplate fp "email/new-team-owner-welcome.txt" + <*> readTemplate fp "email/new-team-owner-welcome.html" + <*> pure (emailSender gOptions) + <*> readText fp "email/sender.txt" ) where gOptions = o.emailSMS.general diff --git a/services/brig/src/Brig/Version.hs b/services/brig/src/Brig/Version.hs index 86f3a33b937..606639c9830 100644 --- a/services/brig/src/Brig/Version.hs +++ b/services/brig/src/Brig/Version.hs @@ -26,7 +26,7 @@ import Wire.API.Routes.Named import Wire.API.Routes.Version versionAPI :: ServerT VersionAPI (Handler r) -versionAPI = Named $ do +versionAPI = Named @"get-version" $ do fed <- asks (.federator) dom <- viewFederationDomain disabled <- asks (.disabledVersions) diff --git a/services/brig/test/integration/API/Search.hs b/services/brig/test/integration/API/Search.hs index 14832d5e377..44ea01d712d 100644 --- a/services/brig/test/integration/API/Search.hs +++ b/services/brig/test/integration/API/Search.hs @@ -1,6 +1,7 @@ {-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE PartialTypeSignatures #-} {-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE RecordWildCards #-} {-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} {-# OPTIONS_GHC -Wno-partial-type-signatures #-} {-# OPTIONS_GHC -Wno-redundant-constraints #-} @@ -33,12 +34,17 @@ import API.User.Util import Bilge import Bilge.Assert import Brig.App (initHttpManagerWithTLSConfig) +import Brig.Index.Eval (runCommand) +import Brig.Index.Options +import Brig.Index.Options qualified as IndexOpts +import Brig.Options (ElasticSearchOpts) import Brig.Options qualified as Opt import Brig.Options qualified as Opts +import Cassandra qualified as C +import Cassandra.Options qualified as CassOpts import Control.Lens ((.~), (?~), (^.)) -import Control.Monad.Catch (MonadCatch, MonadThrow) -import Control.Retry -import Data.Aeson (FromJSON, Value, decode) +import Control.Monad.Catch (MonadCatch) +import Data.Aeson (Value, decode) import Data.Aeson qualified as Aeson import Data.Domain (Domain (Domain)) import Data.Handle (fromHandle) @@ -58,6 +64,7 @@ import Network.Wai qualified as Wai import Network.Wai.Handler.Warp qualified as Warp import Network.Wai.Test qualified as WaiTest import Safe (headMay) +import System.Logger qualified as Log import Test.QuickCheck (Arbitrary (arbitrary), generate) import Test.Tasty import Test.Tasty.HUnit @@ -65,6 +72,7 @@ import Text.RawString.QQ (r) import URI.ByteString qualified as URI import UnliftIO (Concurrently (..), async, bracket, cancel, runConcurrently) import Util +import Util.Options (Endpoint) import Wire.API.Federation.API.Brig (SearchResponse (SearchResponse)) import Wire.API.Team.Feature import Wire.API.Team.SearchVisibility @@ -90,7 +98,11 @@ tests opts mgr galley brig = do testWithBothIndices opts mgr "Non ascii names" $ testSearchNonAsciiNames brig, testWithBothIndices opts mgr "user with umlaut" $ testSearchWithUmlaut brig, testWithBothIndices opts mgr "user with japanese name" $ testSearchCJK brig, - test mgr "migration to new index" $ testMigrationToNewIndex opts brig, + testGroup "index migration" $ + [ test mgr "migration to new index from existing index" $ testMigrationToNewIndex opts brig runReindexFromAnotherIndex, + test mgr "migration to new index from database" $ testMigrationToNewIndex opts brig (runReindexFromDatabase Reindex), + test mgr "migration to new index from database (force sync)" $ testMigrationToNewIndex opts brig (runReindexFromDatabase ReindexSameOrNewer) + ], testGroup "team A: SearchVisibilityStandard (= unrestricted outbound search)" $ [ testGroup "team A: SearchableByOwnTeam (= restricted inbound search)" $ [ testWithBothIndices opts mgr " I. non-team user cannot find team A member by display name" $ testSearchTeamMemberAsNonMemberDisplayName mgr brig galley FeatureStatusDisabled, @@ -608,8 +620,13 @@ testSearchOtherDomain opts brig = do -- cluster. This test spins up a proxy server to pass requests to our only ES -- server. The proxy server ensures that only requests to the 'old' index go -- through. -testMigrationToNewIndex :: (TestConstraints m, MonadUnliftIO m) => Opt.Opts -> Brig -> m () -testMigrationToNewIndex opts brig = do +testMigrationToNewIndex :: + (TestConstraints m, MonadUnliftIO m) => + Opt.Opts -> + Brig -> + (Log.Logger -> Opt.Opts -> ES.IndexName -> IO ()) -> + m () +testMigrationToNewIndex opts brig reindexCommand = do withOldESProxy opts $ \oldESUrl oldESIndex -> do let optsOldIndex = opts @@ -652,9 +669,11 @@ testMigrationToNewIndex opts brig = do assertCanFindByName brig phase1TeamUser1 phase2TeamUser -- Run Migrations - let newIndexName = opts ^. Opt.elasticsearchLens . Opt.indexLens - taskNodeId <- assertRight =<< runBH opts (ES.reindexAsync $ ES.mkReindexRequest (ES.IndexName oldESIndex) newIndexName) - runBH opts $ waitForTaskToComplete @ES.ReindexResponse taskNodeId + let oldIndexName = ES.IndexName oldESIndex + logger <- Log.create Log.StdOut + liftIO $ do + createCommand logger opts oldIndexName + reindexCommand logger opts oldIndexName -- Phase 3: Using old index for search, writing to both indices, migrations have run refreshIndex brig @@ -688,6 +707,71 @@ testMigrationToNewIndex opts brig = do assertCanFindByName brig phase1TeamUser1 phase3NonTeamUser assertCanFindByName brig phase1TeamUser1 phase3TeamUser +createCommand :: Log.Logger -> Opt.Opts -> ES.IndexName -> IO () +createCommand logger opts oldIndexName = + let newIndexName = opts ^. Opt.elasticsearchLens . Opt.indexLens + esOldOpts :: Opt.ElasticSearchOpts = (opts ^. Opt.elasticsearchLens) & (Opt.indexLens .~ oldIndexName) + esOldConnectionSettings :: ESConnectionSettings = toESConnectionSettings esOldOpts + esNewConnectionSettings = esOldConnectionSettings {esIndex = newIndexName} + replicas = 2 + shards = 2 + refreshInterval = 5 + esSettings = + IndexOpts.localElasticSettings + & IndexOpts.esConnection .~ esNewConnectionSettings + & IndexOpts.esIndexReplicas .~ ES.ReplicaCount replicas + & IndexOpts.esIndexShardCount .~ shards + & IndexOpts.esIndexRefreshInterval .~ refreshInterval + in runCommand logger $ Create esSettings opts.galley + +runReindexFromAnotherIndex :: Log.Logger -> Opt.Opts -> ES.IndexName -> IO () +runReindexFromAnotherIndex logger opts oldIndexName = + let newIndexName = opts ^. Opt.elasticsearchLens . Opt.indexLens + esOldOpts :: Opt.ElasticSearchOpts = (opts ^. Opt.elasticsearchLens) & (Opt.indexLens .~ oldIndexName) + esOldConnectionSettings :: ESConnectionSettings = toESConnectionSettings esOldOpts + reindexSettings = ReindexFromAnotherIndexSettings esOldConnectionSettings newIndexName 5 + in runCommand logger $ ReindexFromAnotherIndex reindexSettings + +runReindexFromDatabase :: + (ElasticSettings -> CassandraSettings -> Endpoint -> Command) -> + Log.Logger -> + Opt.Opts -> + ES.IndexName -> + IO () +runReindexFromDatabase syncCommand logger opts oldIndexName = + let newIndexName = opts ^. Opt.elasticsearchLens . Opt.indexLens + esOldOpts :: Opt.ElasticSearchOpts = (opts ^. Opt.elasticsearchLens) & (Opt.indexLens .~ oldIndexName) + esOldConnectionSettings :: ESConnectionSettings = toESConnectionSettings esOldOpts + esNewConnectionSettings = esOldConnectionSettings {esIndex = newIndexName} + replicas = 2 + shards = 2 + refreshInterval = 5 + elasticSettings :: ElasticSettings = + IndexOpts.localElasticSettings + & IndexOpts.esConnection .~ esNewConnectionSettings + & IndexOpts.esIndexReplicas .~ ES.ReplicaCount replicas + & IndexOpts.esIndexShardCount .~ shards + & IndexOpts.esIndexRefreshInterval .~ refreshInterval + cassandraSettings :: CassandraSettings = + ( localCassandraSettings + & IndexOpts.cHost .~ (Text.unpack opts.cassandra.endpoint.host) + & IndexOpts.cPort .~ (opts.cassandra.endpoint.port) + & IndexOpts.cKeyspace .~ (C.Keyspace opts.cassandra.keyspace) + ) + + endpoint :: Endpoint = opts.galley + in runCommand logger $ syncCommand elasticSettings cassandraSettings endpoint + +toESConnectionSettings :: ElasticSearchOpts -> ESConnectionSettings +toESConnectionSettings opts = ESConnectionSettings {..} + where + toText (ES.Server url) = url + esServer = (fromRight undefined . URI.parseURI URI.strictURIParserOptions . Text.encodeUtf8 . toText) opts.url + esIndex = opts.index + esCaCert = opts.caCert + esInsecureSkipVerifyTls = opts.insecureSkipVerifyTls + esCredentials = opts.credentials + withOldESProxy :: (TestConstraints m, MonadUnliftIO m, HasCallStack) => Opt.Opts -> (Text -> Text -> m a) -> m a withOldESProxy opts f = do indexName <- randomHandle @@ -712,18 +796,6 @@ indexProxyServer idx opts mgr = else Wai.WPRResponse (Wai.responseLBS HTTP.status400 [] $ "Refusing to proxy to path=" <> cs (Wai.rawPathInfo req)) in waiProxyTo proxyApp Wai.defaultOnExc mgr -waitForTaskToComplete :: forall a m. (ES.MonadBH m, MonadThrow m, FromJSON a) => ES.TaskNodeId -> m () -waitForTaskToComplete taskNodeId = do - let policy = constantDelay 100000 <> limitRetries 30 - let retryCondition _ = fmap not . isTaskComplete - task <- retrying policy retryCondition (const $ ES.getTask @m @a taskNodeId) - taskCompleted <- isTaskComplete task - liftIO $ assertBool "Timed out waiting for task" taskCompleted - where - isTaskComplete :: Either ES.EsError (ES.TaskResponse a) -> m Bool - isTaskComplete (Left e) = liftIO $ assertFailure $ "Expected Right, got Left: " <> show e - isTaskComplete (Right taskRes) = pure $ ES.taskResponseCompleted taskRes - testWithBothIndices :: Opt.Opts -> Manager -> TestName -> WaiTest.Session a -> TestTree testWithBothIndices opts mgr name f = do testGroup diff --git a/services/brig/test/integration/API/Team/Util.hs b/services/brig/test/integration/API/Team/Util.hs index 12258c5dbd5..ccfd18e9c21 100644 --- a/services/brig/test/integration/API/Team/Util.hs +++ b/services/brig/test/integration/API/Team/Util.hs @@ -428,7 +428,7 @@ stdInvitationRequest = stdInvitationRequest' Nothing Nothing stdInvitationRequest' :: Maybe Locale -> Maybe Role -> EmailAddress -> InvitationRequest stdInvitationRequest' loc role email = - InvitationRequest loc role Nothing email + InvitationRequest loc role Nothing email True setTeamTeamSearchVisibilityAvailable :: (HasCallStack, MonadHttp m, MonadIO m, MonadCatch m) => Galley -> TeamId -> FeatureStatus -> m () setTeamTeamSearchVisibilityAvailable galley tid status = diff --git a/services/brig/test/integration/API/UserPendingActivation.hs b/services/brig/test/integration/API/UserPendingActivation.hs index b82eb251957..6120eaafca1 100644 --- a/services/brig/test/integration/API/UserPendingActivation.hs +++ b/services/brig/test/integration/API/UserPendingActivation.hs @@ -107,7 +107,8 @@ createScimToken spar' owner = do { description = "testCreateToken", password = Just defPassword, verificationCode = Nothing, - name = Nothing + name = Nothing, + idp = Nothing } pure tok diff --git a/services/cannon/cannon.cabal b/services/cannon/cannon.cabal index 1eb1b4cdd26..25ad0624593 100644 --- a/services/cannon/cannon.cabal +++ b/services/cannon/cannon.cabal @@ -23,6 +23,8 @@ library Cannon.App Cannon.Dict Cannon.Options + Cannon.RabbitMq + Cannon.RabbitMqConsumerApp Cannon.Run Cannon.Types Cannon.WS @@ -79,13 +81,17 @@ library build-depends: aeson >=2.0.1.0 + , amqp , api-field-json-th >=0.1.0.2 , async >=2.0 , base >=4.6 && <5 , bilge >=0.12 + , binary , bytestring >=0.10 , bytestring-conversion >=0.2 + , cassandra-util , conduit >=1.3.4.2 + , containers , data-timeout >=0.3 , exceptions >=0.6 , extended @@ -95,6 +101,7 @@ library , hs-opentelemetry-sdk , http-types >=0.8 , imports + , kan-extensions , lens >=4.4 , lens-family-core >=1.1 , metrics-wai >=0.4 @@ -107,6 +114,7 @@ library , strict >=0.3.2 , text >=1.1 , tinylog >=0.10 + , transformers , types-common >=0.16 , unix , unliftio diff --git a/services/cannon/cannon.integration.yaml b/services/cannon/cannon.integration.yaml index e7e7985fea8..9aeca3249f5 100644 --- a/services/cannon/cannon.integration.yaml +++ b/services/cannon/cannon.integration.yaml @@ -12,10 +12,24 @@ cannon: externalHost: 127.0.0.1 #externalHostFile: /etc/wire/cannon/cannon-host.txt +cassandra: + endpoint: + host: 127.0.0.1 + port: 9042 + keyspace: gundeck_test + gundeck: host: 127.0.0.1 port: 8086 +rabbitmq: + host: 127.0.0.1 + port: 5671 + vHost: / + enableTls: true + caCert: test/resources/rabbitmq-ca.pem + insecureSkipVerifyTls: false + drainOpts: gracePeriodSeconds: 1 millisecondsBetweenBatches: 500 diff --git a/services/cannon/cannon2.integration.yaml b/services/cannon/cannon2.integration.yaml index cb5fb6c371e..1fc81233c38 100644 --- a/services/cannon/cannon2.integration.yaml +++ b/services/cannon/cannon2.integration.yaml @@ -16,6 +16,14 @@ gundeck: host: 127.0.0.1 port: 8086 +rabbitmq: + host: 127.0.0.1 + port: 5671 + vHost: / + enableTls: true + caCert: test/resources/rabbitmq-ca.pem + insecureSkipVerifyTls: false + drainOpts: gracePeriodSeconds: 1 millisecondsBetweenBatches: 5 diff --git a/services/cannon/default.nix b/services/cannon/default.nix index c0e94ff02f7..c62056faa23 100644 --- a/services/cannon/default.nix +++ b/services/cannon/default.nix @@ -4,13 +4,17 @@ # dependencies are added or removed. { mkDerivation , aeson +, amqp , api-field-json-th , async , base , bilge +, binary , bytestring , bytestring-conversion +, cassandra-util , conduit +, containers , criterion , data-timeout , exceptions @@ -22,6 +26,7 @@ , hs-opentelemetry-sdk , http-types , imports +, kan-extensions , lens , lens-family-core , lib @@ -40,6 +45,7 @@ , tasty-quickcheck , text , tinylog +, transformers , types-common , unix , unliftio @@ -61,13 +67,17 @@ mkDerivation { isExecutable = true; libraryHaskellDepends = [ aeson + amqp api-field-json-th async base bilge + binary bytestring bytestring-conversion + cassandra-util conduit + containers data-timeout exceptions extended @@ -77,6 +87,7 @@ mkDerivation { hs-opentelemetry-sdk http-types imports + kan-extensions lens lens-family-core metrics-wai @@ -89,6 +100,7 @@ mkDerivation { strict text tinylog + transformers types-common unix unliftio diff --git a/services/cannon/src/Cannon/API/Public.hs b/services/cannon/src/Cannon/API/Public.hs index 4a559f9f17c..e895429ae8b 100644 --- a/services/cannon/src/Cannon/API/Public.hs +++ b/services/cannon/src/Cannon/API/Public.hs @@ -21,6 +21,7 @@ module Cannon.API.Public where import Cannon.App (wsapp) +import Cannon.RabbitMqConsumerApp (rabbitMQWebSocketApp) import Cannon.Types import Cannon.WS import Control.Monad.IO.Class @@ -32,9 +33,16 @@ import Wire.API.Routes.Named import Wire.API.Routes.Public.Cannon publicAPIServer :: ServerT CannonAPI Cannon -publicAPIServer = Named @"await-notifications" streamData +publicAPIServer = + Named @"await-notifications" streamData + :<|> Named @"consume-events" consumeEvents streamData :: UserId -> ConnId -> Maybe ClientId -> PendingConnection -> Cannon () streamData userId connId clientId con = do e <- wsenv liftIO $ wsapp (mkKey userId connId) clientId e con + +consumeEvents :: UserId -> ClientId -> PendingConnection -> Cannon () +consumeEvents userId clientId con = do + e <- wsenv + liftIO $ rabbitMQWebSocketApp userId clientId e con diff --git a/services/cannon/src/Cannon/App.hs b/services/cannon/src/Cannon/App.hs index 770bf0ff499..2ad956a087c 100644 --- a/services/cannon/src/Cannon/App.hs +++ b/services/cannon/src/Cannon/App.hs @@ -15,12 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Cannon.App - ( wsapp, - terminate, - maxPingInterval, - ) -where +module Cannon.App where import Cannon.WS import Control.Concurrent.Async diff --git a/services/cannon/src/Cannon/Options.hs b/services/cannon/src/Cannon/Options.hs index ae301862e1b..aa1d1e8d005 100644 --- a/services/cannon/src/Cannon/Options.hs +++ b/services/cannon/src/Cannon/Options.hs @@ -30,18 +30,26 @@ module Cannon.Options logNetStrings, logFormat, drainOpts, + rabbitmq, + cassandraOpts, + rabbitMqMaxConnections, + rabbitMqMaxChannels, Opts, gracePeriodSeconds, millisecondsBetweenBatches, minBatchSize, disabledAPIVersions, DrainOpts, + validateOpts, ) where +import Cassandra.Options (CassandraOpts) import Control.Lens (makeFields) +import Data.Aeson import Data.Aeson.APIFieldJsonTH import Imports +import Network.AMQP.Extended (AmqpEndpoint) import System.Logger.Extended (Level, LogFormat) import Wire.API.Routes.Version @@ -74,7 +82,7 @@ data DrainOpts = DrainOpts -- there are not many users connected. Must not be set to 0. _drainOptsMillisecondsBetweenBatches :: Word64, -- | Batch size is calculated considering actual number of websockets and - -- gracePeriod. If this number is too little, '_drainOptsMinBatchSize' is + -- gracePeriod. If this number is too small, '_drainOptsMinBatchSize' is -- used. _drainOptsMinBatchSize :: Word64 } @@ -87,14 +95,40 @@ deriveApiFieldJSON ''DrainOpts data Opts = Opts { _optsCannon :: !Cannon, _optsGundeck :: !Gundeck, + _optsRabbitmq :: !AmqpEndpoint, _optsLogLevel :: !Level, _optsLogNetStrings :: !(Maybe (Last Bool)), _optsLogFormat :: !(Maybe (Last LogFormat)), _optsDrainOpts :: DrainOpts, - _optsDisabledAPIVersions :: !(Set VersionExp) + _optsDisabledAPIVersions :: !(Set VersionExp), + _optsCassandraOpts :: !CassandraOpts, + -- | Maximum number of rabbitmq connections. Must be strictly positive. + _optsRabbitMqMaxConnections :: Int, + -- | Maximum number of rabbitmq channels per connection. Must be strictly positive. + _optsRabbitMqMaxChannels :: Int } - deriving (Eq, Show, Generic) + deriving (Show, Generic) makeFields ''Opts -deriveApiFieldJSON ''Opts +validateOpts :: Opts -> IO () +validateOpts opts = do + when (opts._optsRabbitMqMaxConnections <= 0) $ do + fail "rabbitMqMaxConnections must be strictly positive" + when (opts._optsRabbitMqMaxChannels <= 0) $ do + fail "rabbitMqMaxChannels must be strictly positive" + +instance FromJSON Opts where + parseJSON = withObject "CannonOpts" $ \o -> + Opts + <$> o .: "cannon" + <*> o .: "gundeck" + <*> o .: "rabbitmq" + <*> o .: "logLevel" + <*> o .:? "logNetStrings" + <*> o .:? "logFormat" + <*> o .: "drainOpts" + <*> o .: "disabledAPIVersions" + <*> o .: "cassandra" + <*> o .:? "rabbitMqMaxConnections" .!= 1000 + <*> o .:? "rabbitMqMaxChannels" .!= 300 diff --git a/services/cannon/src/Cannon/RabbitMq.hs b/services/cannon/src/Cannon/RabbitMq.hs new file mode 100644 index 00000000000..ea1ef92feb6 --- /dev/null +++ b/services/cannon/src/Cannon/RabbitMq.hs @@ -0,0 +1,332 @@ +{-# LANGUAGE RecordWildCards #-} + +module Cannon.RabbitMq + ( RabbitMqPoolException, + RabbitMqPoolOptions (..), + RabbitMqPool, + createRabbitMqPool, + drainRabbitMqPool, + RabbitMqChannel (..), + createChannel, + getMessage, + ackMessage, + ) +where + +import Cannon.Options +import Control.Concurrent.Async +import Control.Concurrent.Timeout +import Control.Exception +import Control.Lens ((^.)) +import Control.Monad.Codensity +import Control.Monad.Trans.Except +import Control.Monad.Trans.Maybe +import Control.Retry +import Data.ByteString.Conversion +import Data.List.Extra +import Data.Map qualified as Map +import Data.Text qualified as T +import Data.Timeout +import Imports hiding (threadDelay) +import Network.AMQP qualified as Q +import Network.AMQP.Extended +import System.Logger (Logger) +import System.Logger qualified as Log +import UnliftIO (pooledMapConcurrentlyN_) + +data RabbitMqPoolException + = TooManyChannels + | ChannelClosed + deriving (Eq, Show) + +instance Exception RabbitMqPoolException + +data PooledConnection key = PooledConnection + { connId :: Word64, + inner :: Q.Connection, + channels :: !(Map key Q.Channel) + } + +data RabbitMqPool key = RabbitMqPool + { opts :: RabbitMqPoolOptions, + nextId :: TVar Word64, + connections :: TVar [PooledConnection key], + -- | draining mode + draining :: TVar Bool, + logger :: Logger, + deadVar :: MVar () + } + +data RabbitMqPoolOptions = RabbitMqPoolOptions + { maxConnections :: Int, + maxChannels :: Int, + endpoint :: AmqpEndpoint, + retryEnabled :: Bool + } + +createRabbitMqPool :: (Ord key) => RabbitMqPoolOptions -> Logger -> Codensity IO (RabbitMqPool key) +createRabbitMqPool opts logger = Codensity $ bracket create destroy + where + create = do + deadVar <- newEmptyMVar + (nextId, connections, draining) <- + atomically $ + (,,) <$> newTVar 0 <*> newTVar [] <*> newTVar False + let pool = RabbitMqPool {..} + -- create one connection + void $ createConnection pool + pure pool + destroy pool = putMVar pool.deadVar () + +drainRabbitMqPool :: (ToByteString key) => RabbitMqPool key -> DrainOpts -> IO () +drainRabbitMqPool pool opts = do + atomically $ writeTVar pool.draining True + + channels <- atomically $ do + conns <- readTVar pool.connections + pure $ concat [Map.assocs c.channels | c <- conns] + let numberOfChannels = fromIntegral (length channels) + + let maxNumberOfBatches = + (opts ^. gracePeriodSeconds * 1000) + `div` (opts ^. millisecondsBetweenBatches) + computedBatchSize = numberOfChannels `div` maxNumberOfBatches + batchSize = max (opts ^. minBatchSize) computedBatchSize + + logDraining + pool.logger + numberOfChannels + batchSize + (opts ^. minBatchSize) + computedBatchSize + maxNumberOfBatches + + -- Sleep for the grace period + 1 second. If the sleep completes, it means + -- that draining didn't finish, and we should log that. + withAsync + ( do + -- Allocate 1 second more than the grace period to allow for overhead of + -- spawning threads. + liftIO $ threadDelay $ ((opts ^. gracePeriodSeconds) # Second + 1 # Second) + logExpired pool.logger (opts ^. gracePeriodSeconds) + ) + $ \_ -> do + for_ (chunksOf (fromIntegral batchSize) channels) $ \batch -> do + -- 16 was chosen with a roll of a fair dice. + concurrently + (pooledMapConcurrentlyN_ 16 (closeChannel pool.logger) batch) + (liftIO $ threadDelay ((opts ^. millisecondsBetweenBatches) # MilliSecond)) + Log.info pool.logger $ Log.msg (Log.val "Draining complete") + where + closeChannel :: (ToByteString key) => Log.Logger -> (key, Q.Channel) -> IO () + closeChannel l (key, chan) = do + Log.info l $ + Log.msg (Log.val "closing rabbitmq channel") + . Log.field "key" (toByteString' key) + Q.closeChannel chan + + logExpired :: Log.Logger -> Word64 -> IO () + logExpired l period = do + Log.err l $ Log.msg (Log.val "Drain grace period expired") . Log.field "gracePeriodSeconds" period + + logDraining :: Log.Logger -> Word64 -> Word64 -> Word64 -> Word64 -> Word64 -> IO () + logDraining l count b minB batchSize m = do + Log.info l $ + Log.msg (Log.val "draining all rabbitmq channels") + . Log.field "numberOfChannels" count + . Log.field "computedBatchSize" b + . Log.field "minBatchSize" minB + . Log.field "batchSize" batchSize + . Log.field "maxNumberOfBatches" m + +createConnection :: (Ord key) => RabbitMqPool key -> IO (PooledConnection key) +createConnection pool = mask_ $ do + conn <- openConnection pool + mpconn <- runMaybeT . atomically $ do + -- do not create new connections when in draining mode + readTVar pool.draining >>= guard . not + connId <- readTVar pool.nextId + writeTVar pool.nextId $! succ connId + let c = + PooledConnection + { connId = connId, + channels = mempty, + inner = conn + } + modifyTVar pool.connections (c :) + pure c + pconn <- maybe (throwIO TooManyChannels) pure mpconn + + closedVar <- newEmptyMVar + -- Fire and forget: the thread will terminate by itself as soon as the + -- connection is closed (or if the pool is destroyed). + -- Asynchronous exception safety is guaranteed because exceptions are masked + -- in this whole block. + void . async $ do + v <- race (takeMVar closedVar) (readMVar pool.deadVar) + when (isRight v) $ + -- close connection and ignore exceptions + catch @SomeException (Q.closeConnection conn) $ + \_ -> pure () + atomically $ do + conns <- readTVar pool.connections + writeTVar pool.connections $ + filter (\c -> c.connId /= pconn.connId) conns + Q.addConnectionClosedHandler conn True $ do + putMVar closedVar () + pure pconn + +openConnection :: RabbitMqPool key -> IO Q.Connection +openConnection pool = do + -- This might not be the correct connection ID that will eventually be + -- assigned to this connection, since there are potential races with other + -- connections being opened at the same time. However, this is only used to + -- name the connection, and we only rely on names for tests, so it is fine. + connId <- readTVarIO pool.nextId + (username, password) <- readCredsFromEnv + recovering + rabbitMqRetryPolicy + ( skipAsyncExceptions + <> [logRetries (const $ pure True) (logConnectionError pool.logger)] + ) + ( const $ do + Log.info pool.logger $ + Log.msg (Log.val "Trying to connect to RabbitMQ") + mTlsSettings <- + traverse + (liftIO . (mkTLSSettings pool.opts.endpoint.host)) + pool.opts.endpoint.tls + liftIO $ + Q.openConnection'' $ + Q.defaultConnectionOpts + { Q.coServers = + [ ( pool.opts.endpoint.host, + fromIntegral pool.opts.endpoint.port + ) + ], + Q.coVHost = pool.opts.endpoint.vHost, + Q.coAuth = [Q.plain username password], + Q.coTLSSettings = fmap Q.TLSCustom mTlsSettings, + -- the name is used by tests to identify pool connections + Q.coName = Just ("pool " <> T.pack (show connId)) + } + ) + +data RabbitMqChannel = RabbitMqChannel + { -- | The current channel. The var is empty while the channel is being + -- re-established. + inner :: MVar Q.Channel, + msgVar :: MVar (Maybe (Q.Message, Q.Envelope)) + } + +getMessage :: RabbitMqChannel -> IO (Q.Message, Q.Envelope) +getMessage chan = takeMVar chan.msgVar >>= maybe (throwIO ChannelClosed) pure + +ackMessage :: RabbitMqChannel -> Word64 -> Bool -> IO () +ackMessage chan deliveryTag multiple = do + inner <- readMVar chan.inner + Q.ackMsg inner deliveryTag multiple + +createChannel :: (Ord key) => RabbitMqPool key -> Text -> key -> Codensity IO RabbitMqChannel +createChannel pool queue key = do + closedVar <- lift newEmptyMVar + inner <- lift newEmptyMVar + msgVar <- lift newEmptyMVar + + let handleException e = do + retry <- case (Q.isNormalChannelClose e, fromException e) of + (True, _) -> do + Log.info pool.logger $ + Log.msg (Log.val "RabbitMQ channel is closed normally, not attempting to reopen channel") + pure False + (_, Just (Q.ConnectionClosedException {})) -> do + Log.info pool.logger $ + Log.msg (Log.val "RabbitMQ connection was closed unexpectedly") + pure pool.opts.retryEnabled + _ -> do + unless (fromException e == Just AsyncCancelled) $ + logException pool.logger "RabbitMQ channel closed" e + pure pool.opts.retryEnabled + putMVar closedVar retry + + let manageChannel = do + retry <- lowerCodensity $ do + conn <- Codensity $ bracket (acquireConnection pool) (releaseConnection pool key) + chan <- Codensity $ bracket (Q.openChannel conn.inner) $ \c -> + catch (Q.closeChannel c) $ \(_ :: SomeException) -> pure () + connSize <- atomically $ do + let conn' = conn {channels = Map.insert key chan conn.channels} + conns <- readTVar pool.connections + writeTVar pool.connections $! + map (\c -> if c.connId == conn'.connId then conn' else c) conns + pure $ Map.size conn'.channels + if connSize > pool.opts.maxChannels + then pure True + else do + liftIO $ Q.addChannelExceptionHandler chan handleException + putMVar inner chan + void $ liftIO $ Q.consumeMsgs chan queue Q.Ack $ \(message, envelope) -> do + putMVar msgVar (Just (message, envelope)) + retry <- takeMVar closedVar + void $ takeMVar inner + pure retry + + when retry manageChannel + + void $ + Codensity $ + withAsync $ + catch manageChannel handleException + `finally` putMVar msgVar Nothing + pure RabbitMqChannel {inner = inner, msgVar = msgVar} + +acquireConnection :: (Ord key) => RabbitMqPool key -> IO (PooledConnection key) +acquireConnection pool = do + findConnection pool >>= \case + Nothing -> do + bracketOnError + (createConnection pool) + (Q.closeConnection . (.inner)) + $ \conn -> do + -- if we have too many connections at this point, give up + numConnections <- atomically $ length <$> readTVar pool.connections + when (numConnections > pool.opts.maxConnections) $ + throw TooManyChannels + pure conn + Just conn -> pure conn + +findConnection :: RabbitMqPool key -> IO (Maybe (PooledConnection key)) +findConnection pool = (either throwIO pure <=< (atomically . runExceptT . runMaybeT)) $ do + conns <- lift . lift $ readTVar pool.connections + guard (notNull conns) + + let pconn = minimumOn (Map.size . (.channels)) $ conns + when (Map.size pconn.channels >= pool.opts.maxChannels) $ + if length conns >= pool.opts.maxConnections + then lift $ throwE TooManyChannels + else mzero + pure pconn + +releaseConnection :: (Ord key) => RabbitMqPool key -> key -> PooledConnection key -> IO () +releaseConnection pool key conn = atomically $ do + modifyTVar pool.connections $ map $ \c -> + if c.connId == conn.connId + then c {channels = Map.delete key c.channels} + else c + +logConnectionError :: Logger -> Bool -> SomeException -> RetryStatus -> IO () +logConnectionError l willRetry e retryStatus = do + Log.err l $ + Log.msg (Log.val "Failed to connect to RabbitMQ") + . Log.field "error" (displayException @SomeException e) + . Log.field "willRetry" willRetry + . Log.field "retryCount" retryStatus.rsIterNumber + +logException :: (MonadIO m) => Logger -> String -> SomeException -> m () +logException l m (SomeException e) = do + Log.err l $ + Log.msg m + . Log.field "error" (displayException e) + +rabbitMqRetryPolicy :: RetryPolicyM IO +rabbitMqRetryPolicy = limitRetriesByCumulativeDelay 1_000_000 $ fullJitterBackoff 1000 diff --git a/services/cannon/src/Cannon/RabbitMqConsumerApp.hs b/services/cannon/src/Cannon/RabbitMqConsumerApp.hs new file mode 100644 index 00000000000..ede22279071 --- /dev/null +++ b/services/cannon/src/Cannon/RabbitMqConsumerApp.hs @@ -0,0 +1,222 @@ +{-# LANGUAGE RecordWildCards #-} + +module Cannon.RabbitMqConsumerApp where + +import Cannon.App (rejectOnError) +import Cannon.RabbitMq +import Cannon.WS hiding (env) +import Cassandra as C hiding (batch) +import Control.Concurrent.Async +import Control.Exception (Handler (..), bracket, catch, catches, throwIO, try) +import Control.Lens hiding ((#)) +import Control.Monad.Codensity +import Data.Aeson hiding (Key) +import Data.Id +import Imports hiding (min, threadDelay) +import Network.AMQP qualified as Q +import Network.WebSockets +import Network.WebSockets qualified as WS +import System.Logger qualified as Log +import Wire.API.Event.WebSocketProtocol +import Wire.API.Notification + +rabbitMQWebSocketApp :: UserId -> ClientId -> Env -> ServerApp +rabbitMQWebSocketApp uid cid e pendingConn = do + bracket openWebSocket closeWebSocket $ \wsConn -> + ( do + sendFullSyncMessageIfNeeded wsConn uid cid e + sendNotifications wsConn + ) + `catches` [ handleClientMisbehaving wsConn, + handleWebSocketExceptions wsConn, + handleOtherExceptions wsConn + ] + where + logClient = + Log.field "user" (idToText uid) + . Log.field "client" (clientToText cid) + + openWebSocket = + acceptRequest pendingConn + `catch` rejectOnError pendingConn + + closeWebSocket wsConn = do + logCloseWebsocket + -- ignore any exceptions when sending the close message + void . try @SomeException $ WS.sendClose wsConn ("" :: ByteString) + + getEventData :: RabbitMqChannel -> IO EventData + getEventData chan = do + (msg, envelope) <- getMessage chan + case eitherDecode @QueuedNotification msg.msgBody of + Left err -> do + logParseError err + -- This message cannot be parsed, make sure it doesn't requeue. There + -- is no need to throw an error and kill the websocket as this is + -- probably caused by a bug or someone messing with RabbitMQ. + -- + -- The bug case is slightly dangerous as it could drop a lot of events + -- en masse, if at some point we decide that Events should not be + -- pushed as JSONs, hopefully we think of the parsing side if/when + -- that happens. + Q.rejectEnv envelope False + -- try again + getEventData chan + Right notif -> do + logEvent notif + pure $ EventData notif envelope.envDeliveryTag + + handleWebSocketExceptions wsConn = + Handler $ + \(err :: WS.ConnectionException) -> do + case err of + CloseRequest code reason -> + Log.debug e.logg $ + Log.msg (Log.val "Client requested to close connection") + . Log.field "status_code" code + . Log.field "reason" reason + . logClient + ConnectionClosed -> + Log.info e.logg $ + Log.msg (Log.val "Client closed tcp connection abruptly") + . logClient + _ -> do + Log.info e.logg $ + Log.msg (Log.val "Failed to receive message, closing websocket") + . Log.field "error" (displayException err) + . logClient + WS.sendCloseCode wsConn 1003 ("websocket-failure" :: ByteString) + + handleClientMisbehaving wsConn = + Handler $ \(err :: WebSocketServerError) -> do + case err of + FailedToParseClientMessage _ -> do + Log.info e.logg $ + Log.msg (Log.val "Failed to parse received message, closing websocket") + . logClient + WS.sendCloseCode wsConn 1003 ("failed-to-parse" :: ByteString) + UnexpectedAck -> do + Log.info e.logg $ + Log.msg (Log.val "Client sent unexpected ack message") + . logClient + WS.sendCloseCode wsConn 1003 ("unexpected-ack" :: ByteString) + + handleOtherExceptions wsConn = Handler $ + \(err :: SomeException) -> do + WS.sendCloseCode wsConn 1003 ("internal-error" :: ByteString) + throwIO err + + sendNotifications :: WS.Connection -> IO () + sendNotifications wsConn = lowerCodensity $ do + let key = mkKeyRabbit uid cid + chan <- createChannel e.pool (clientNotificationQueueName uid cid) key + + let consumeRabbitMq = forever $ do + eventData <- getEventData chan + catch (WS.sendBinaryData wsConn (encode (EventMessage eventData))) $ + \(err :: SomeException) -> do + logSendFailure err + throwIO err + + -- get ack from websocket and forward to rabbitmq + let consumeWebsocket = forever $ do + getClientMessage wsConn >>= \case + AckFullSync -> throwIO UnexpectedAck + AckMessage ackData -> do + logAckReceived ackData + void $ ackMessage chan ackData.deliveryTag ackData.multiple + + -- run both loops concurrently, so that + -- - notifications are delivered without having to wait for acks + -- - exceptions on either side do not cause a deadlock + lift $ concurrently_ consumeRabbitMq consumeWebsocket + + logParseError :: String -> IO () + logParseError err = + Log.err e.logg $ + Log.msg (Log.val "failed to decode event from the queue as a JSON") + . logClient + . Log.field "parse_error" err + + logEvent :: QueuedNotification -> IO () + logEvent event = + Log.debug e.logg $ + Log.msg (Log.val "got event") + . logClient + . Log.field "event" (encode event) + + logSendFailure :: SomeException -> IO () + logSendFailure err = + Log.err e.logg $ + Log.msg (Log.val "Pushing to WS failed, closing connection") + . Log.field "error" (displayException err) + . logClient + + logAckReceived :: AckData -> IO () + logAckReceived ackData = + Log.debug e.logg $ + Log.msg (Log.val "Received ACK") + . Log.field "delivery_tag" ackData.deliveryTag + . Log.field "multiple" ackData.multiple + . logClient + + logCloseWebsocket :: IO () + logCloseWebsocket = + Log.debug e.logg $ + Log.msg (Log.val "Closing the websocket") + . logClient + +-- | Check if client has missed messages. If so, send a full synchronisation +-- message and wait for the corresponding ack. +sendFullSyncMessageIfNeeded :: + WS.Connection -> + UserId -> + ClientId -> + Env -> + IO () +sendFullSyncMessageIfNeeded wsConn uid cid env = do + row <- C.runClient env.cassandra do + retry x5 $ query1 q (params LocalQuorum (uid, cid)) + for_ row $ \_ -> sendFullSyncMessage uid cid wsConn env + where + q :: PrepQuery R (UserId, ClientId) (Identity (Maybe UserId)) + q = + [sql| SELECT user_id FROM missed_notifications + WHERE user_id = ? and client_id = ? + |] + +sendFullSyncMessage :: + UserId -> + ClientId -> + WS.Connection -> + Env -> + IO () +sendFullSyncMessage uid cid wsConn env = do + let event = encode EventFullSync + WS.sendBinaryData wsConn event + getClientMessage wsConn >>= \case + AckMessage _ -> throwIO UnexpectedAck + AckFullSync -> + C.runClient env.cassandra do + retry x1 $ write delete (params LocalQuorum (uid, cid)) + where + delete :: PrepQuery W (UserId, ClientId) () + delete = + [sql| + DELETE FROM missed_notifications + WHERE user_id = ? and client_id = ? + |] + +getClientMessage :: WS.Connection -> IO MessageClientToServer +getClientMessage wsConn = do + msg <- WS.receiveData wsConn + case eitherDecode msg of + Left err -> throwIO (FailedToParseClientMessage err) + Right m -> pure m + +data WebSocketServerError + = FailedToParseClientMessage String + | UnexpectedAck + deriving (Show) + +instance Exception WebSocketServerError diff --git a/services/cannon/src/Cannon/Run.hs b/services/cannon/src/Cannon/Run.hs index eefd22f4af5..eff9a612f81 100644 --- a/services/cannon/src/Cannon/Run.hs +++ b/services/cannon/src/Cannon/Run.hs @@ -27,14 +27,17 @@ import Cannon.API.Public import Cannon.App (maxPingInterval) import Cannon.Dict qualified as D import Cannon.Options -import Cannon.Types (Cannon, applog, clients, env, mkEnv, runCannon, runCannonToServant) -import Cannon.WS hiding (env) +import Cannon.RabbitMq +import Cannon.Types hiding (Env) +import Cannon.WS hiding (drainOpts, env) +import Cassandra.Util (defInitCassandra) import Control.Concurrent import Control.Concurrent.Async qualified as Async import Control.Exception qualified as E import Control.Exception.Safe (catchAny) import Control.Lens ((^.)) -import Control.Monad.Catch (MonadCatch, finally) +import Control.Monad.Catch (MonadCatch) +import Control.Monad.Codensity import Data.Metrics.Servant import Data.Proxy import Data.Text (pack, strip) @@ -65,23 +68,33 @@ import Wire.OpenTelemetry (withTracer) type CombinedAPI = CannonAPI :<|> Internal.API run :: Opts -> IO () -run o = withTracer \tracer -> do +run o = lowerCodensity $ do + lift $ validateOpts o + tracer <- Codensity withTracer when (o ^. drainOpts . millisecondsBetweenBatches == 0) $ error "drainOpts.millisecondsBetweenBatches must not be set to 0." when (o ^. drainOpts . gracePeriodSeconds == 0) $ error "drainOpts.gracePeriodSeconds must not be set to 0." - ext <- loadExternal - g <- L.mkLogger (o ^. logLevel) (o ^. logNetStrings) (o ^. logFormat) - e <- - mkEnv ext o g - <$> D.empty 128 - <*> newManager defaultManagerSettings {managerConnCount = 128} - <*> createSystemRandom - <*> mkClock - refreshMetricsThread <- Async.async $ runCannon e refreshMetrics + ext <- lift loadExternal + g <- + Codensity $ + E.bracket + (L.mkLogger (o ^. logLevel) (o ^. logNetStrings) (o ^. logFormat)) + L.close + cassandra <- lift $ defInitCassandra (o ^. cassandraOpts) g + + e <- do + d1 <- D.empty numDictSlices + d2 <- D.empty numDictSlices + man <- lift $ newManager defaultManagerSettings {managerConnCount = 128} + rnd <- lift createSystemRandom + clk <- lift mkClock + mkEnv ext o cassandra g d1 d2 man rnd clk (o ^. Cannon.Options.rabbitmq) + + void $ Codensity $ Async.withAsync $ runCannon e refreshMetrics s <- newSettings $ Server (o ^. cannon . host) (o ^. cannon . port) (applog e) (Just idleTimeout) - otelMiddleWare <- newOpenTelemetryWaiMiddleware + otelMiddleWare <- lift newOpenTelemetryWaiMiddleware let middleware :: Wai.Middleware middleware = versionMiddleware (foldMap expandVersionExp (o ^. disabledAPIVersions)) @@ -96,17 +109,20 @@ run o = withTracer \tracer -> do server = hoistServer (Proxy @CannonAPI) (runCannonToServant e) publicAPIServer :<|> hoistServer (Proxy @Internal.API) (runCannonToServant e) internalServer - tid <- myThreadId - E.handle uncaughtExceptionHandler $ do - void $ installHandler sigTERM (signalHandler (env e) tid) Nothing - void $ installHandler sigINT (signalHandler (env e) tid) Nothing - inSpan tracer "cannon" defaultSpanArguments {kind = Otel.Server} (runSettings s app) `finally` do + tid <- lift myThreadId + + Codensity $ \k -> + inSpan tracer "cannon" defaultSpanArguments {kind = Otel.Server} (k ()) + lift $ + E.handle uncaughtExceptionHandler $ do + let handler = signalHandler (env e) (o ^. drainOpts) tid + void $ installHandler sigTERM handler Nothing + void $ installHandler sigINT handler Nothing -- FUTUREWORK(@akshaymankar, @fisx): we may want to call `runSettingsWithShutdown` here, -- but it's a sensitive change, and it looks like this is closing all the websockets at -- the same time and then calling the drain script. I suspect this might be due to some -- cleanup in wai. this needs to be tested very carefully when touched. - Async.cancel refreshMetricsThread - L.close (applog e) + runSettings s app where idleTimeout = fromIntegral $ maxPingInterval + 3 -- Each cannon instance advertises its own location (ip or dns name) to gundeck. @@ -118,9 +134,10 @@ run o = withTracer \tracer -> do readExternal :: FilePath -> IO ByteString readExternal f = encodeUtf8 . strip . pack <$> Strict.readFile f -signalHandler :: Env -> ThreadId -> Signals.Handler -signalHandler e mainThread = CatchOnce $ do +signalHandler :: Env -> DrainOpts -> ThreadId -> Signals.Handler +signalHandler e opts mainThread = CatchOnce $ do runWS e drain + drainRabbitMqPool e.pool opts throwTo mainThread SignalledToExit -- | This is called when the main thread receives the exception generated by diff --git a/services/cannon/src/Cannon/Types.hs b/services/cannon/src/Cannon/Types.hs index 6fa37b78a65..146bd5519bd 100644 --- a/services/cannon/src/Cannon/Types.hs +++ b/services/cannon/src/Cannon/Types.hs @@ -18,12 +18,9 @@ -- with this program. If not, see . module Cannon.Types - ( Env, - opts, - applog, - dict, - env, + ( Env (..), Cannon, + numDictSlices, mapConcurrentlyCannon, mkEnv, runCannon, @@ -37,27 +34,36 @@ import Bilge (Manager) import Bilge.RPC (HasRequestId (..)) import Cannon.Dict (Dict) import Cannon.Options +import Cannon.RabbitMq import Cannon.WS (Clock, Key, Websocket) import Cannon.WS qualified as WS +import Cassandra (ClientState) import Control.Concurrent.Async (mapConcurrently) import Control.Lens ((^.)) import Control.Monad.Catch +import Control.Monad.Codensity import Data.Id import Data.Text.Encoding import Imports +import Network.AMQP qualified as Q +import Network.AMQP.Extended (AmqpEndpoint) import Prometheus import Servant qualified import System.Logger qualified as Logger import System.Logger.Class hiding (info) import System.Random.MWC (GenIO) +numDictSlices :: Int +numDictSlices = 128 + ----------------------------------------------------------------------------- -- Cannon monad data Env = Env { opts :: !Opts, applog :: !Logger, - dict :: !(Dict Key Websocket), + websockets :: !(Dict Key Websocket), + rabbitConnections :: (Dict Key Q.Connection), reqId :: !RequestId, env :: !WS.Env } @@ -94,21 +100,46 @@ instance HasRequestId Cannon where mkEnv :: ByteString -> Opts -> + ClientState -> Logger -> Dict Key Websocket -> + Dict Key Q.Connection -> Manager -> GenIO -> Clock -> - Env -mkEnv external o l d p g t = - Env o l d (RequestId defRequestId) $ - WS.env external (o ^. cannon . port) (encodeUtf8 $ o ^. gundeck . host) (o ^. gundeck . port) l p d g t (o ^. drainOpts) + AmqpEndpoint -> + Codensity IO Env +mkEnv external o cs l d conns p g t endpoint = do + let poolOpts = + RabbitMqPoolOptions + { endpoint = endpoint, + maxConnections = o ^. rabbitMqMaxConnections, + maxChannels = o ^. rabbitMqMaxChannels, + retryEnabled = False + } + pool <- createRabbitMqPool poolOpts l + let wsEnv = + WS.env + external + (o ^. cannon . port) + (encodeUtf8 $ o ^. gundeck . host) + (o ^. gundeck . port) + l + p + d + conns + g + t + (o ^. drainOpts) + cs + pool + pure $ Env o l d conns (RequestId defRequestId) wsEnv runCannon :: Env -> Cannon a -> IO a runCannon e c = runReaderT (unCannon c) e clients :: Cannon (Dict Key Websocket) -clients = Cannon $ asks dict +clients = Cannon $ asks websockets wsenv :: Cannon WS.Env wsenv = Cannon $ do diff --git a/services/cannon/src/Cannon/WS.hs b/services/cannon/src/Cannon/WS.hs index ea106f4cf03..1653c82fbd9 100644 --- a/services/cannon/src/Cannon/WS.hs +++ b/services/cannon/src/Cannon/WS.hs @@ -18,7 +18,7 @@ -- with this program. If not, see . module Cannon.WS - ( Env, + ( Env (..), WS, env, runWS, @@ -40,6 +40,7 @@ module Cannon.WS connIdent, Key, mkKey, + mkKeyRabbit, key2bytes, client, sendMsg, @@ -52,12 +53,15 @@ import Bilge.Retry import Cannon.Dict (Dict) import Cannon.Dict qualified as D import Cannon.Options (DrainOpts, gracePeriodSeconds, millisecondsBetweenBatches, minBatchSize) +import Cannon.RabbitMq +import Cassandra (ClientState) import Conduit import Control.Concurrent.Timeout import Control.Lens ((^.)) import Control.Monad.Catch import Control.Retry import Data.Aeson hiding (Error, Key) +import Data.Binary.Builder qualified as B import Data.ByteString.Char8 (pack) import Data.ByteString.Conversion import Data.ByteString.Lazy qualified as L @@ -67,6 +71,7 @@ import Data.List.Extra (chunksOf) import Data.Text.Encoding (decodeUtf8) import Data.Timeout (TimeoutUnit (..), (#)) import Imports hiding (threadDelay) +import Network.AMQP qualified as Q import Network.HTTP.Types.Method import Network.HTTP.Types.Status import Network.Wai.Utilities.Error @@ -83,11 +88,17 @@ import Wire.API.Presence newtype Key = Key { _key :: (ByteString, ByteString) } - deriving (Eq, Show, Hashable) + deriving (Eq, Show, Hashable, Ord) mkKey :: UserId -> ConnId -> Key mkKey u c = Key (toByteString' u, fromConnId c) +mkKeyRabbit :: UserId -> ClientId -> Key +mkKeyRabbit u c = Key (toByteString' u, toByteString' c) + +instance ToByteString Key where + builder = B.fromByteString . key2bytes + key2bytes :: Key -> ByteString key2bytes (Key (u, c)) = u <> "." <> c @@ -142,10 +153,13 @@ data Env = Env reqId :: !RequestId, logg :: !Logger, manager :: !Manager, - dict :: !(Dict Key Websocket), + websockets :: !(Dict Key Websocket), + rabbitConnections :: !(Dict Key Q.Connection), rand :: !GenIO, clock :: !Clock, - drainOpts :: DrainOpts + drainOpts :: DrainOpts, + cassandra :: ClientState, + pool :: RabbitMqPool Key } setRequestId :: RequestId -> Env -> Env @@ -188,11 +202,14 @@ env :: Logger -> Manager -> Dict Key Websocket -> + Dict Key Q.Connection -> GenIO -> Clock -> DrainOpts -> + ClientState -> + RabbitMqPool Key -> Env -env leh lp gh gp = Env leh lp (host gh . port gp $ empty) (RequestId defRequestId) +env leh lp gh gp = Env leh lp (Bilge.host gh . Bilge.port gp $ empty) (RequestId defRequestId) runWS :: (MonadIO m) => Env -> WS a -> m a runWS e m = liftIO $ runReaderT (_conn m) e @@ -200,13 +217,13 @@ runWS e m = liftIO $ runReaderT (_conn m) e registerLocal :: Key -> Websocket -> WS () registerLocal k c = do trace $ client (key2bytes k) . msg (val "register") - d <- WS $ asks dict + d <- WS $ asks websockets D.insert k c d unregisterLocal :: Key -> Websocket -> WS Bool unregisterLocal k c = do trace $ client (key2bytes k) . msg (val "unregister") - d <- WS $ asks dict + d <- WS $ asks websockets D.removeIf (maybe False ((connIdent c ==) . connIdent)) k d registerRemote :: Key -> Maybe ClientId -> WS () @@ -244,7 +261,7 @@ sendMsg message k c = do traceLog m = trace $ client kb . msg (logMsg m) logMsg :: (WebSocketsData a) => a -> Builder - logMsg m = val "sendMsgConduit: \"" +++ L.take 128 (toLazyByteString m) +++ val "...\"" + logMsg m = val "sendMsgConduit: \"" +++ L.take 129 (toLazyByteString m) +++ val "...\"" kb = key2bytes k @@ -288,7 +305,7 @@ sendMsg message k c = do drain :: WS () drain = do opts <- asks drainOpts - websockets <- asks dict + websockets <- asks websockets numberOfConns <- fromIntegral <$> D.size websockets let maxNumberOfBatches = (opts ^. gracePeriodSeconds * 1000) `div` (opts ^. millisecondsBetweenBatches) computedBatchSize = numberOfConns `div` maxNumberOfBatches diff --git a/services/cannon/test/resources/rabbitmq-ca.pem b/services/cannon/test/resources/rabbitmq-ca.pem new file mode 100644 index 00000000000..2aa8d89e4ac --- /dev/null +++ b/services/cannon/test/resources/rabbitmq-ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUBbMHNT+GZgCVyopxX3sciD+E5uowDQYJKoZIhvcNAQEL +BQAwIjEgMB4GA1UEAwwXcmFiYml0bXEuY2EuZXhhbXBsZS5jb20wHhcNMjQwOTAz +MTIwMzQwWhcNMzQwOTAxMTIwMzQwWjAiMSAwHgYDVQQDDBdyYWJiaXRtcS5jYS5l +eGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJP2rB1X +qxpRAE6hSYkYbfd/pdfOrVbSwsZqYj866ijrtZFh0+AWtjYUkiLFYGiXINZdns6S +LVP8afPCXKynjZ/2zzIpuvX51zhHtrulxBcKyN85gckm03KGLz5GNGFNl8CeYJfu +RWJSA+AOxkR28CkBBD5eR0cRc8j0E9buDtY36wmgqEtkDvAc4PvmgAIPL2KovmmM +ohGy2hHJZLKCA+QGzeLUqQx8MTF3RsajV8ttRg+wfUQM6wdMJbub93wmgLVfncaQ +dO9E/jEVr2kU0WeJ1kmxs40d1bd02U3/8omGyayRGX9qqfaF3g+oDzAoiF7LbDuC +7VNVEc8/PP1t6b0CAwEAAaNTMFEwHQYDVR0OBBYEFOv/4GK9l7p7p9nk2hf/59sD +PhEVMB8GA1UdIwQYMBaAFOv/4GK9l7p7p9nk2hf/59sDPhEVMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADggEBABt+JodEGOjnFA+VCnRWOGl1q4wlcEbl ++5mEuVwwWGzbispmJxIdf+FlOotonvhksGQUDZ3gr7FvLcsGy6OnOK2YBSLOcnRP +amKPaiQwB38VcxQEUOL+1ZqLsLTseGJUCkGk+OmfjInqCURS5jRUbVtYZiqkzD40 +7Rz5iyrXwv1vbuXpW2s/kUgD6dLrRwt1ydaxCbA3C92farZJFvpUwTyhAXUkKyPZ +Hgu5E/nppujH2h6nOJfHGcyaVHai7pDManjO1icWmfx+t2s94rdAEevvBu0k/qL4 +tXWWSh81MtGjLjQ88ozbmr7/LSo3KaAB7M/AnZdL3JjtmFy9eFhqQaY= +-----END CERTIFICATE----- diff --git a/services/cargohold/src/CargoHold/App.hs b/services/cargohold/src/CargoHold/App.hs index 5acb66a57ed..ef292fdaef7 100644 --- a/services/cargohold/src/CargoHold/App.hs +++ b/services/cargohold/src/CargoHold/App.hs @@ -53,8 +53,8 @@ import Bilge.RPC (HasRequestId (..)) import qualified CargoHold.AWS as AWS import CargoHold.Options (AWSOpts, Opts, S3Compatibility (..), brig) import qualified CargoHold.Options as Opt -import Control.Error (ExceptT, exceptT) -import Control.Exception (throw) +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.Id @@ -241,4 +241,12 @@ executeBrigInteral action = do type Handler = ExceptT Error App runHandler :: Env -> Handler a -> IO a -runHandler e h = runAppT e (exceptT throw pure h) +runHandler env h = + catch + (runAppT env (runExceptT h) >>= either throwIO pure) + $ \(e :: SomeException) -> do + Log.err env.appLogger $ + Log.msg ("IO Exception occurred" :: ByteString) + . Log.field "message" (displayException e) + . Log.field "request" (unRequestId env.requestId) + throwIO e diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index 6d29d6081ca..41dee1da8d2 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -292,7 +292,7 @@ evalGalley e = . interpretExternalAccess . runRpcWithHttp (e ^. manager) (e ^. reqId) . runGundeckAPIAccess (e ^. options . gundeck) - . runNotificationSubsystemGundeck (notificationSubssystemConfig e) + . runNotificationSubsystemGundeck (notificationSubsystemConfig e) . interpretSparAccess . interpretBrigAccess where diff --git a/services/galley/src/Galley/Env.hs b/services/galley/src/Galley/Env.hs index 9d88c703b86..d5c8bc23c67 100644 --- a/services/galley/src/Galley/Env.hs +++ b/services/galley/src/Galley/Env.hs @@ -109,8 +109,8 @@ currentFanoutLimit o = do let maxSize = fromIntegral (o ^. (O.settings . maxTeamSize)) unsafeRange (min maxSize optFanoutLimit) -notificationSubssystemConfig :: Env -> NotificationSubsystemConfig -notificationSubssystemConfig env = +notificationSubsystemConfig :: Env -> NotificationSubsystemConfig +notificationSubsystemConfig env = NotificationSubsystemConfig { chunkSize = defaultChunkSize, fanoutLimit = currentFanoutLimit env._options, diff --git a/services/galley/test/integration/API/Util.hs b/services/galley/test/integration/API/Util.hs index 07909cbbbcd..4e10d03a738 100644 --- a/services/galley/test/integration/API/Util.hs +++ b/services/galley/test/integration/API/Util.hs @@ -29,7 +29,7 @@ import Control.Concurrent.Async import Control.Lens hiding (from, to, uncons, (#), (.=)) import Control.Monad.Catch (MonadCatch, MonadMask) import Control.Monad.Codensity (lowerCodensity) -import Control.Retry (constantDelay, exponentialBackoff, limitRetries, retrying) +import Control.Retry (constantDelay, exponentialBackoff, limitRetries, recoverAll, retrying) import Data.Aeson hiding (json) import Data.Aeson qualified as A import Data.Aeson.Lens (key, _String) @@ -426,10 +426,11 @@ addUserToTeamWithRole' :: (HasCallStack) => Maybe Role -> UserId -> TeamId -> Te addUserToTeamWithRole' role inviter tid = do brig <- viewBrig inviteeEmail <- randomEmail - let invite = InvitationRequest Nothing role Nothing inviteeEmail + let invite = InvitationRequest Nothing role Nothing inviteeEmail True invResponse <- postInvitation tid inviter invite inv <- responseJsonError invResponse - inviteeCode <- getInvitationCode tid inv.invitationId + inviteeCode <- recoverAll (exponentialBackoff 1000 <> limitRetries 11) $ + \_ -> getInvitationCode tid inv.invitationId r <- post ( brig diff --git a/services/gundeck/default.nix b/services/gundeck/default.nix index 7629d0712c5..9901752a620 100644 --- a/services/gundeck/default.nix +++ b/services/gundeck/default.nix @@ -9,6 +9,7 @@ , amazonka-core , amazonka-sns , amazonka-sqs +, amqp , async , attoparsec , auto-update @@ -22,6 +23,7 @@ , containers , criterion , crypton-x509-store +, data-timeout , errors , exceptions , extended @@ -69,6 +71,7 @@ , tasty-hunit , tasty-quickcheck , text +, these , time , tinylog , tls @@ -98,6 +101,7 @@ mkDerivation { amazonka-core amazonka-sns amazonka-sqs + amqp async attoparsec auto-update @@ -108,6 +112,7 @@ mkDerivation { cassandra-util containers crypton-x509-store + data-timeout errors exceptions extended @@ -135,6 +140,7 @@ mkDerivation { servant servant-server text + these time tinylog tls diff --git a/services/gundeck/gundeck.cabal b/services/gundeck/gundeck.cabal index 19ce66fb3e2..75b62eccb54 100644 --- a/services/gundeck/gundeck.cabal +++ b/services/gundeck/gundeck.cabal @@ -46,6 +46,7 @@ library Gundeck.Schema.V1 Gundeck.Schema.V10 Gundeck.Schema.V11 + Gundeck.Schema.V12 Gundeck.Schema.V2 Gundeck.Schema.V3 Gundeck.Schema.V4 @@ -116,6 +117,7 @@ library , amazonka-core >=2 , amazonka-sns >=2 , amazonka-sqs >=2 + , amqp , async >=2.0 , attoparsec >=0.10 , auto-update >=0.1 @@ -126,6 +128,7 @@ library , cassandra-util >=0.16.2 , containers >=0.5 , crypton-x509-store + , data-timeout , errors >=2.0 , exceptions >=0.4 , extended @@ -153,6 +156,7 @@ library , servant , servant-server , text >=1.1 + , these , time >=1.4 , tinylog >=0.10 , tls >=1.7.0 diff --git a/services/gundeck/gundeck.integration.yaml b/services/gundeck/gundeck.integration.yaml index adf2914f6aa..075ddccfc5c 100644 --- a/services/gundeck/gundeck.integration.yaml +++ b/services/gundeck/gundeck.integration.yaml @@ -34,6 +34,14 @@ aws: sqsEndpoint: http://localhost:4568 # https://sqs.eu-west-1.amazonaws.com snsEndpoint: http://localhost:4575 # https://sns.eu-west-1.amazonaws.com +rabbitmq: + host: 127.0.0.1 + port: 5671 + vHost: / + enableTls: true + caCert: test/resources/rabbitmq-ca.pem + insecureSkipVerifyTls: false + settings: httpPoolSize: 1024 notificationTTL: 24192200 diff --git a/services/gundeck/src/Gundeck/API/Internal.hs b/services/gundeck/src/Gundeck/API/Internal.hs index f0dfabe1d19..d97a0a695dd 100644 --- a/services/gundeck/src/Gundeck/API/Internal.hs +++ b/services/gundeck/src/Gundeck/API/Internal.hs @@ -24,6 +24,7 @@ where import Cassandra qualified import Control.Lens (view) import Data.Id +import Gundeck.Client import Gundeck.Client qualified as Client import Gundeck.Monad import Gundeck.Presence qualified as Presence @@ -49,6 +50,7 @@ servantSitemap = :<|> Named @"i-clients-delete" unregisterClientH :<|> Named @"i-user-delete" removeUserH :<|> Named @"i-push-tokens-get" getPushTokensH + :<|> Named @"i-reg-consumable-notifs" registerConsumableNotifcationsClient statusH :: (Applicative m) => m NoContent statusH = pure NoContent @@ -64,3 +66,9 @@ removeUserH uid = NoContent <$ Client.removeUser uid getPushTokensH :: UserId -> Gundeck PushTok.PushTokenList getPushTokensH uid = PushTok.PushTokenList <$> (view PushTok.addrPushToken <$$> PushTok.lookup uid Cassandra.All) + +registerConsumableNotifcationsClient :: UserId -> ClientId -> Gundeck NoContent +registerConsumableNotifcationsClient uid cid = do + chan <- getRabbitMqChan + void . liftIO $ setupConsumableNotifications chan uid cid + pure NoContent diff --git a/services/gundeck/src/Gundeck/Client.hs b/services/gundeck/src/Gundeck/Client.hs index 43323ade9cc..486ab4b63c8 100644 --- a/services/gundeck/src/Gundeck/Client.hs +++ b/services/gundeck/src/Gundeck/Client.hs @@ -15,19 +15,20 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Gundeck.Client - ( unregister, - removeUser, - ) -where +module Gundeck.Client where import Control.Lens (view) import Data.Id +import Data.Map qualified as Map +import Data.Text.Encoding (encodeUtf8) import Gundeck.Monad import Gundeck.Notification.Data qualified as Notifications import Gundeck.Push.Data qualified as Push import Gundeck.Push.Native import Imports +import Network.AMQP +import Network.AMQP.Types +import Wire.API.Notification unregister :: UserId -> ClientId -> Gundeck () unregister uid cid = do @@ -42,3 +43,32 @@ removeUser user = do deleteTokens toks Nothing Push.erase user Notifications.deleteAll user + +setupConsumableNotifications :: + Channel -> + UserId -> + ClientId -> + IO Text +setupConsumableNotifications chan uid cid = do + let qName = clientNotificationQueueName uid cid + void $ + declareQueue + chan + newQueue + { queueName = qName, + -- TODO: make this less ugly to read + queueHeaders = + FieldTable $ + Map.fromList + [ ( "x-dead-letter-exchange", + FVString $ + encodeUtf8 userNotificationDlxName + ), + ( "x-dead-letter-routing-key", + FVString $ encodeUtf8 userNotificationDlqName + ) + ] + } + for_ [userRoutingKey uid, clientRoutingKey uid cid] $ + bindQueue chan qName userNotificationExchangeName + pure qName diff --git a/services/gundeck/src/Gundeck/Env.hs b/services/gundeck/src/Gundeck/Env.hs index 2397005c68a..ed91feeaafc 100644 --- a/services/gundeck/src/Gundeck/Env.hs +++ b/services/gundeck/src/Gundeck/Env.hs @@ -41,6 +41,8 @@ import Gundeck.Redis qualified as Redis import Gundeck.Redis.HedisExtensions qualified as Redis import Gundeck.ThreadBudget import Imports +import Network.AMQP (Channel) +import Network.AMQP.Extended qualified as Q import Network.HTTP.Client (responseTimeoutMicro) import Network.HTTP.Client.TLS (tlsManagerSettings) import Network.TLS as TLS @@ -58,7 +60,8 @@ data Env = Env _rstateAdditionalWrite :: !(Maybe Redis.RobustConnection), _awsEnv :: !Aws.Env, _time :: !(IO Milliseconds), - _threadBudgetState :: !(Maybe ThreadBudgetState) + _threadBudgetState :: !(Maybe ThreadBudgetState), + _rabbitMqChannel :: MVar Channel } makeLenses ''Env @@ -101,7 +104,8 @@ createEnv o = do { updateAction = Ms . round . (* 1000) <$> getPOSIXTime } mtbs <- mkThreadBudgetState `mapM` (o ^. settings . maxConcurrentNativePushes) - pure $! (rThread : rAdditionalThreads,) $! Env (RequestId defRequestId) o l n p r rAdditional a io mtbs + rabbitMqChannelMVar <- Q.mkRabbitMqChannelMVar l (o ^. rabbitmq) + pure $! (rThread : rAdditionalThreads,) $! Env (RequestId defRequestId) o l n p r rAdditional a io mtbs rabbitMqChannelMVar reqIdMsg :: RequestId -> Logger.Msg -> Logger.Msg reqIdMsg = ("request" Logger..=) . unRequestId diff --git a/services/gundeck/src/Gundeck/Monad.hs b/services/gundeck/src/Gundeck/Monad.hs index 6d4147ea70a..3e1881dc2cc 100644 --- a/services/gundeck/src/Gundeck/Monad.hs +++ b/services/gundeck/src/Gundeck/Monad.hs @@ -32,6 +32,7 @@ module Gundeck.Monad runDirect, runGundeck, posixTime, + getRabbitMqChan, -- * Select which redis to target runWithDefaultRedis, @@ -53,11 +54,15 @@ import Database.Redis qualified as Redis import Gundeck.Env import Gundeck.Redis qualified as Redis import Imports +import Network.AMQP +import Network.HTTP.Types import Network.Wai +import Network.Wai.Utilities.Error import Prometheus -import System.Logger qualified as Log +import System.Logger (Logger) import System.Logger qualified as Logger -import System.Logger.Class +import System.Logger.Class qualified as Log +import System.Timeout import UnliftIO (async) -- | TODO: 'Client' already has an 'Env'. Why do we need two? How does this even work? We should @@ -99,7 +104,7 @@ newtype WithDefaultRedis a = WithDefaultRedis {runWithDefaultRedis :: Gundeck a} MonadReader Env, MonadClient, MonadUnliftIO, - MonadLogger + Log.MonadLogger ) instance Redis.MonadRedis WithDefaultRedis where @@ -128,7 +133,7 @@ newtype WithAdditionalRedis a = WithAdditionalRedis {runWithAdditionalRedis :: G MonadReader Env, MonadClient, MonadUnliftIO, - MonadLogger + Log.MonadLogger ) instance Redis.MonadRedis WithAdditionalRedis where @@ -148,7 +153,7 @@ instance Redis.RedisCtx WithAdditionalRedis (Either Redis.Reply) where returnDecode :: (Redis.RedisResult a) => Redis.Reply -> WithAdditionalRedis (Either Redis.Reply a) returnDecode = Redis.liftRedis . Redis.returnDecode -instance MonadLogger Gundeck where +instance Log.MonadLogger Gundeck where log l m = do e <- ask Logger.log (e ^. applog) l (reqIdMsg (e ^. reqId) . m) @@ -173,7 +178,7 @@ runDirect e m = `catch` ( \(exception :: SomeException) -> do case fromException exception of Nothing -> - Log.err (e ^. applog) $ + Logger.err (e ^. applog) $ Log.msg ("IO Exception occurred" :: ByteString) . Log.field "message" (displayException exception) . Log.field "request" (unRequestId (e ^. reqId)) @@ -186,16 +191,23 @@ lookupReqId l r = case lookup requestIdName (requestHeaders r) of Just rid -> pure $ RequestId rid Nothing -> do localRid <- RequestId . UUID.toASCIIBytes <$> UUID.nextRandom - Log.info l $ - "request-id" - .= localRid - ~~ "method" - .= requestMethod r - ~~ "path" - .= rawPathInfo r - ~~ msg (val "generated a new request id for local request") + Logger.info l $ + Log.field "request-id" localRid + . Log.field "method" (requestMethod r) + . Log.field "path" (rawPathInfo r) + . Log.msg (Log.val "generated a new request id for local request") pure localRid posixTime :: Gundeck Milliseconds posixTime = view time >>= liftIO {-# INLINE posixTime #-} + +getRabbitMqChan :: Gundeck Channel +getRabbitMqChan = do + chanMVar <- view rabbitMqChannel + mChan <- liftIO $ System.Timeout.timeout 1_000_000 $ readMVar chanMVar + case mChan of + Nothing -> do + Log.err $ Log.msg (Log.val "Could not retrieve RabbitMQ channel") + throwM $ mkError status500 "internal-server-error" "Could not retrieve RabbitMQ channel" + Just chan -> pure chan diff --git a/services/gundeck/src/Gundeck/Options.hs b/services/gundeck/src/Gundeck/Options.hs index 5f67081e178..f09b6177d19 100644 --- a/services/gundeck/src/Gundeck/Options.hs +++ b/services/gundeck/src/Gundeck/Options.hs @@ -25,6 +25,7 @@ import Data.Aeson.TH import Data.Yaml (FromJSON) import Gundeck.Aws.Arn import Imports +import Network.AMQP.Extended import System.Logger.Extended (Level, LogFormat) import Util.Options import Util.Options.Common @@ -133,6 +134,7 @@ data Opts = Opts _redis :: !RedisEndpoint, _redisAdditionalWrite :: !(Maybe RedisEndpoint), _aws :: !AWSOpts, + _rabbitmq :: !AmqpEndpoint, _discoUrl :: !(Maybe Text), _settings :: !Settings, -- Logging diff --git a/services/gundeck/src/Gundeck/Push.hs b/services/gundeck/src/Gundeck/Push.hs index 098223a5547..994a8987eff 100644 --- a/services/gundeck/src/Gundeck/Push.hs +++ b/services/gundeck/src/Gundeck/Push.hs @@ -29,19 +29,25 @@ module Gundeck.Push ) where +import Bilge qualified +import Control.Arrow ((&&&)) import Control.Error import Control.Exception (ErrorCall (ErrorCall)) import Control.Lens (to, view, (.~), (^.)) import Control.Monad.Catch import Control.Monad.Except (throwError) -import Data.Aeson as Aeson (Object) +import Data.Aeson qualified as Aeson +import Data.ByteString.Conversion (toByteString') import Data.Id import Data.List.Extra qualified as List -import Data.List1 (List1, list1) +import Data.List1 (List1, list1, toNonEmpty) import Data.Map qualified as Map +import Data.Misc import Data.Range import Data.Set qualified as Set import Data.Text qualified as Text +import Data.These +import Data.Timeout import Data.UUID qualified as UUID import Gundeck.Aws (endpointUsers) import Gundeck.Aws qualified as Aws @@ -58,15 +64,22 @@ import Gundeck.Push.Websocket qualified as Web import Gundeck.ThreadBudget import Gundeck.Util import Imports +import Network.AMQP (Message (..)) +import Network.AMQP qualified as Q import Network.HTTP.Types import Network.Wai.Utilities import System.Logger.Class (msg, val, (+++), (.=), (~~)) import System.Logger.Class qualified as Log +import UnliftIO (pooledMapConcurrentlyN) +import Util.Options import Wire.API.Internal.Notification +import Wire.API.Notification import Wire.API.Presence (Presence (..)) import Wire.API.Presence qualified as Presence import Wire.API.Push.Token qualified as Public import Wire.API.Push.V2 +import Wire.API.User (UserSet (..)) +import Wire.API.User.Client (Client (..), ClientCapability (..), ClientCapabilityList (..), UserClientsFull (..)) push :: [Push] -> Gundeck () push ps = do @@ -84,6 +97,8 @@ class (MonadThrow m) => MonadPushAll m where mpaPushNative :: Notification -> Priority -> [Address] -> m () mpaForkIO :: m () -> m () mpaRunWithBudget :: Int -> a -> m a -> m a + mpaGetClients :: Set UserId -> m UserClientsFull + mpaPublishToRabbitMq :: Text -> Q.Message -> m () instance MonadPushAll Gundeck where mpaNotificationTTL = view (options . settings . notificationTTL) @@ -94,6 +109,13 @@ instance MonadPushAll Gundeck where mpaPushNative = pushNative mpaForkIO = void . forkIO mpaRunWithBudget = runWithBudget'' + mpaGetClients = getClients + mpaPublishToRabbitMq = publishToRabbitMq + +publishToRabbitMq :: Text -> Q.Message -> Gundeck () +publishToRabbitMq routingKey qMsg = do + chan <- getRabbitMqChan + void $ liftIO $ Q.publishMsg chan userNotificationExchangeName routingKey qMsg -- | Another layer of wrap around 'runWithBudget'. runWithBudget'' :: Int -> a -> Gundeck a -> Gundeck a @@ -123,10 +145,102 @@ instance MonadMapAsync Gundeck where Nothing -> mapAsync f l Just chunkSize -> concat <$> mapM (mapAsync f) (List.chunksOf chunkSize l) +splitPushes :: (MonadPushAll m) => [Push] -> m ([Push], [Push]) +splitPushes ps = do + allUserClients <- mpaGetClients (Set.unions $ map (\p -> Set.map (._recipientId) $ p._pushRecipients.fromRange) ps) + pure . partitionHereThere $ map (splitPush allUserClients) ps + +-- | Split a push into rabbitmq and legacy push. This code exists to help with +-- migration. Once it is completed and old APIs are not supported anymore we can +-- assume everything is meant for RabbtiMQ and stop splitting. +splitPush :: + UserClientsFull -> + Push -> + These Push Push +splitPush clientsFull p = do + let (rabbitmqRecipients, legacyRecipients) = + partitionHereThereRange . rcast @_ @_ @1024 $ + mapRange splitRecipient (rangeSetToList $ p._pushRecipients) + case (runcons rabbitmqRecipients, runcons legacyRecipients) of + (Nothing, _) -> (That p) + (_, Nothing) -> (This p) + (Just (rabbit0, rabbits), Just (legacy0, legacies)) -> + These + p {_pushRecipients = rangeListToSet $ rcons rabbit0 rabbits} + p {_pushRecipients = rangeListToSet $ rcons legacy0 legacies} + where + splitRecipient :: Recipient -> These Recipient Recipient + splitRecipient rcpt = do + let allClients = Map.findWithDefault mempty rcpt._recipientId $ clientsFull.userClientsFull + relevantClients = case rcpt._recipientClients of + RecipientClientsSome cs -> + Set.filter (\c -> c.clientId `elem` toList cs) allClients + RecipientClientsAll -> allClients + isClientForRabbitMq c = ClientSupportsConsumableNotifications `Set.member` c.clientCapabilities.fromClientCapabilityList + (rabbitmqClients, legacyClients) = Set.partition isClientForRabbitMq relevantClients + rabbitmqClientIds = (.clientId) <$> Set.toList rabbitmqClients + legacyClientIds = (.clientId) <$> Set.toList legacyClients + case (rabbitmqClientIds, legacyClientIds) of + ([], _) -> + -- Checking for rabbitmqClientIds first ensures that we fall back to + -- old behaviour even if legacyClientIds is empty too. This way we + -- won't break things before clients are ready for it. + (That rcpt) + (_, []) -> + (This rcpt) + (r : rs, l : ls) -> + These + rcpt {_recipientClients = RecipientClientsSome $ list1 r rs} + rcpt {_recipientClients = RecipientClientsSome $ list1 l ls} + + partitionHereThereRange :: Range 0 m [These a b] -> (Range 0 m [a], Range 0 m [b]) + partitionHereThereRange = + ((&&&) (rconcat . mapRange fst) (rconcat . mapRange snd)) + . mapRange partitionToRange + where + rsingleton0 :: forall x. x -> Range 0 1 [x] + rsingleton0 = rcast . rsingleton + + rnil1 :: forall x. Range 0 1 [x] + rnil1 = rcast rnil + + partitionToRange :: These a b -> (Range 0 1 [a], Range 0 1 [b]) + partitionToRange = \case + (This a) -> (rsingleton0 a, rnil1) + (That b) -> (rnil1, rsingleton0 b) + (These a b) -> (rsingleton0 a, rsingleton0 b) + +getClients :: Set UserId -> Gundeck UserClientsFull +getClients uids = do + fmap mconcat + . pooledMapConcurrentlyN 4 getBatch + . List.chunksOf 100 + $ Set.toList uids + where + getBatch :: [UserId] -> Gundeck UserClientsFull + getBatch uidsChunk = do + r <- do + Endpoint h p <- view $ options . brig + Bilge.post + ( Bilge.host (toByteString' h) + . Bilge.port p + . Bilge.path "/i/clients/full" + . Bilge.json (UserSet $ Set.fromList uidsChunk) + . Bilge.expect2xx + ) + Bilge.responseJsonError r + +pushAll :: (MonadPushAll m, MonadNativeTargets m, MonadMapAsync m, Log.MonadLogger m) => [Push] -> m () +pushAll pushes = do + Log.debug $ msg (val "pushing") . Log.field "pushes" (Aeson.encode pushes) + (rabbitmqPushes, legacyPushes) <- splitPushes pushes + pushAllLegacy legacyPushes + pushAllViaRabbitMq rabbitmqPushes + -- | Construct and send a single bulk push request to the client. Write the 'Notification's from -- the request to C*. Trigger native pushes for all delivery failures notifications. -pushAll :: (MonadPushAll m, MonadNativeTargets m, MonadMapAsync m) => [Push] -> m () -pushAll pushes = do +pushAllLegacy :: (MonadPushAll m, MonadNativeTargets m, MonadMapAsync m) => [Push] -> m () +pushAllLegacy pushes = do newNotifications <- mapM mkNewNotification pushes -- persist push request let cassandraTargets :: [CassandraTargets] @@ -153,6 +267,56 @@ pushAll pushes = do mpaRunWithBudget cost () $ mpaPushNative notif (psh ^. pushNativePriority) =<< nativeTargets psh rcps' alreadySent +pushAllViaRabbitMq :: (MonadPushAll m) => [Push] -> m () +pushAllViaRabbitMq pushes = + for_ pushes $ pushViaRabbitMq + +pushViaRabbitMq :: (MonadPushAll m) => Push -> m () +pushViaRabbitMq p = do + notifId <- mpaMkNotificationId + NotificationTTL ttl <- mpaNotificationTTL + let qMsg = + Q.newMsg + { msgBody = + Aeson.encode + . queuedNotification notifId + $ toNonEmpty p._pushPayload, + msgContentType = Just "application/json", + msgDeliveryMode = + -- Non-persistent messages never hit the disk and so do not + -- survive RabbitMQ node restarts, this is great for transient + -- notifications. + Just + ( if p._pushTransient + then Q.NonPersistent + else Q.Persistent + ), + msgExpiration = + Just + ( if p._pushTransient + then + ( -- Means that if there is no active consumer, this + -- message will never be delivered to anyone. It can + -- still take some time before RabbitMQ forgets about + -- this message because the expiration is only + -- considered for messages which are at the head of a + -- queue. See docs: https://www.rabbitmq.com/docs/ttl + "0" + ) + else showT $ fromIntegral ttl # Second #> MilliSecond + ) + } + routingKeys = + Set.unions $ + flip Set.map (fromRange p._pushRecipients) \r -> + case r._recipientClients of + RecipientClientsAll -> + Set.singleton $ userRoutingKey r._recipientId + RecipientClientsSome (toList -> cs) -> + Set.fromList $ map (clientRoutingKey r._recipientId) cs + for_ routingKeys $ \routingKey -> + mpaPublishToRabbitMq routingKey qMsg + -- | A new notification to be stored in C* and pushed over websockets data NewNotification = NewNotification { nnPush :: Push, @@ -251,7 +415,7 @@ shouldActuallyPush psh rcp pres = not isOrigin && okByPushAllowlist && okByRecip okByRecipientAllowlist :: Bool okByRecipientAllowlist = - case (rcp ^. recipientClients, clientId pres) of + case (rcp ^. recipientClients, pres.clientId) of (RecipientClientsSome cs, Just c) -> c `elem` cs _ -> True diff --git a/services/gundeck/src/Gundeck/Run.hs b/services/gundeck/src/Gundeck/Run.hs index b978b9d6b13..1f73490862b 100644 --- a/services/gundeck/src/Gundeck/Run.hs +++ b/services/gundeck/src/Gundeck/Run.hs @@ -42,6 +42,7 @@ import Gundeck.React import Gundeck.Schema.Run (lastSchemaVersion) import Gundeck.ThreadBudget import Imports +import Network.AMQP import Network.Wai as Wai import Network.Wai.Middleware.Gunzip qualified as GZip import Network.Wai.Middleware.Gzip qualified as GZip @@ -53,49 +54,73 @@ import OpenTelemetry.Trace qualified as Otel import Servant (Handler (Handler), (:<|>) (..)) import Servant qualified import System.Logger qualified as Log +import System.Logger.Class qualified as MonadLogger import UnliftIO.Async qualified as Async import Util.Options +import Wire.API.Notification import Wire.API.Routes.Public.Gundeck (GundeckAPI) import Wire.API.Routes.Version import Wire.API.Routes.Version.Wai import Wire.OpenTelemetry run :: Opts -> IO () -run o = withTracer \tracer -> do - (rThreads, e) <- createEnv o - runClient (e ^. cstate) $ +run opts = withTracer \tracer -> do + (rThreads, env) <- createEnv opts + let logger = env ^. applog + + runDirect env setUpRabbitMqExchangesAndQueues + + runClient (env ^. cstate) $ versionCheck lastSchemaVersion - let l = e ^. applog - s <- newSettings $ defaultServer (unpack . host $ o ^. gundeck) (port $ o ^. gundeck) l - let throttleMillis = fromMaybe defSqsThrottleMillis $ o ^. (settings . sqsThrottleMillis) + s <- newSettings $ defaultServer (unpack . host $ opts ^. gundeck) (port $ opts ^. gundeck) logger + let throttleMillis = fromMaybe defSqsThrottleMillis $ opts ^. (settings . sqsThrottleMillis) - lst <- Async.async $ Aws.execute (e ^. awsEnv) (Aws.listen throttleMillis (runDirect e . onEvent)) - wtbs <- forM (e ^. threadBudgetState) $ \tbs -> Async.async $ runDirect e $ watchThreadBudgetState tbs 10 - wCollectAuth <- Async.async (collectAuthMetrics (Aws._awsEnv (Env._awsEnv e))) + lst <- Async.async $ Aws.execute (env ^. awsEnv) (Aws.listen throttleMillis (runDirect env . onEvent)) + wtbs <- forM (env ^. threadBudgetState) $ \tbs -> Async.async $ runDirect env $ watchThreadBudgetState tbs 10 + wCollectAuth <- Async.async (collectAuthMetrics (Aws._awsEnv (Env._awsEnv env))) - app <- middleware e <*> pure (mkApp e) + app <- middleware env <*> pure (mkApp env) inSpan tracer "gundeck" defaultSpanArguments {kind = Otel.Server} (runSettingsWithShutdown s app Nothing) `finally` do - Log.info l $ Log.msg (Log.val "Shutting down ...") - shutdown (e ^. cstate) + Log.info logger $ Log.msg (Log.val "Shutting down ...") + shutdown (env ^. cstate) Async.cancel lst Async.cancel wCollectAuth forM_ wtbs Async.cancel forM_ rThreads Async.cancel - Redis.disconnect =<< takeMVar (e ^. rstate) - whenJust (e ^. rstateAdditionalWrite) $ (=<<) Redis.disconnect . takeMVar - Log.close (e ^. applog) + Redis.disconnect =<< takeMVar (env ^. rstate) + whenJust (env ^. rstateAdditionalWrite) $ (=<<) Redis.disconnect . takeMVar + Log.close (env ^. applog) where + setUpRabbitMqExchangesAndQueues :: Gundeck () + setUpRabbitMqExchangesAndQueues = do + chan <- getRabbitMqChan + MonadLogger.info $ Log.msg (Log.val "setting up RabbitMQ exchanges and queues") + liftIO $ createUserNotificationsExchange chan + liftIO $ createDeadUserNotificationsExchange chan + + createUserNotificationsExchange :: Channel -> IO () + createUserNotificationsExchange chan = do + declareExchange chan newExchange {exchangeName = userNotificationExchangeName, exchangeType = "direct"} + + createDeadUserNotificationsExchange :: Channel -> IO () + createDeadUserNotificationsExchange chan = do + declareExchange chan newExchange {exchangeName = userNotificationDlxName, exchangeType = "direct"} + + let routingKey = userNotificationDlqName + void $ declareQueue chan newQueue {queueName = userNotificationDlqName} + bindQueue chan userNotificationDlqName userNotificationDlxName routingKey + middleware :: Env -> IO Middleware - middleware e = do + middleware env = do otelMiddleWare <- newOpenTelemetryWaiMiddleware pure $ - versionMiddleware (foldMap expandVersionExp (o ^. settings . disabledAPIVersions)) + versionMiddleware (foldMap expandVersionExp (opts ^. settings . disabledAPIVersions)) . otelMiddleWare - . requestIdMiddleware (e ^. applog) defaultRequestIdHeaderName + . requestIdMiddleware (env ^. applog) defaultRequestIdHeaderName . Metrics.servantPrometheusMiddleware (Proxy @(GundeckAPI :<|> InternalAPI)) . GZip.gunzip . GZip.gzip GZip.def - . catchErrors (e ^. applog) defaultRequestIdHeaderName + . catchErrors (env ^. applog) defaultRequestIdHeaderName mkApp :: Env -> Wai.Application mkApp env0 req cont = do diff --git a/services/gundeck/src/Gundeck/Schema/Run.hs b/services/gundeck/src/Gundeck/Schema/Run.hs index 247f7a69488..34dfebd09df 100644 --- a/services/gundeck/src/Gundeck/Schema/Run.hs +++ b/services/gundeck/src/Gundeck/Schema/Run.hs @@ -23,6 +23,7 @@ import Control.Exception (finally) import Gundeck.Schema.V1 qualified as V1 import Gundeck.Schema.V10 qualified as V10 import Gundeck.Schema.V11 qualified as V11 +import Gundeck.Schema.V12 qualified as V12 import Gundeck.Schema.V2 qualified as V2 import Gundeck.Schema.V3 qualified as V3 import Gundeck.Schema.V4 qualified as V4 @@ -63,5 +64,6 @@ migrations = V8.migration, V9.migration, V10.migration, - V11.migration + V11.migration, + V12.migration ] diff --git a/services/gundeck/src/Gundeck/Schema/V12.hs b/services/gundeck/src/Gundeck/Schema/V12.hs new file mode 100644 index 00000000000..ad2a638f51a --- /dev/null +++ b/services/gundeck/src/Gundeck/Schema/V12.hs @@ -0,0 +1,33 @@ +-- 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 Gundeck.Schema.V12 (migration) where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +migration :: Migration +migration = Migration 12 "Create table `missed_notifications`" $ do + schema' + [r| + CREATE TABLE missed_notifications ( + user_id uuid, + client_id text, + PRIMARY KEY (user_id, client_id) + ); + |] diff --git a/services/gundeck/test/resources/rabbitmq-ca.pem b/services/gundeck/test/resources/rabbitmq-ca.pem new file mode 120000 index 00000000000..ca91c2c31bd --- /dev/null +++ b/services/gundeck/test/resources/rabbitmq-ca.pem @@ -0,0 +1 @@ +../../../../deploy/dockerephemeral/rabbitmq-config/certificates/ca.pem \ No newline at end of file diff --git a/services/gundeck/test/unit/MockGundeck.hs b/services/gundeck/test/unit/MockGundeck.hs index 10bc5806bb6..a747a6570a3 100644 --- a/services/gundeck/test/unit/MockGundeck.hs +++ b/services/gundeck/test/unit/MockGundeck.hs @@ -3,8 +3,6 @@ {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeSynonymInstances #-} {-# OPTIONS_GHC -Wno-orphans #-} --- Disabling to stop warnings on HasCallStack -{-# OPTIONS_GHC -Wno-redundant-constraints #-} -- This file is part of the Wire Server implementation. -- @@ -204,7 +202,7 @@ genMockEnv :: (HasCallStack) => Gen MockEnv genMockEnv = do -- This function generates a 'ClientInfo' that corresponds to one of the -- four scenarios above - let genClientInfo :: (HasCallStack) => UserId -> ClientId -> Gen ClientInfo + let genClientInfo :: UserId -> ClientId -> Gen ClientInfo genClientInfo uid cid = do _ciNativeAddress <- QC.oneof @@ -251,7 +249,7 @@ genMockEnv = do validateMockEnv env & either error (const $ pure env) -- Try to shrink a 'MockEnv' by removing some users from '_meClientInfos'. -shrinkMockEnv :: (HasCallStack) => MockEnv -> [MockEnv] +shrinkMockEnv :: MockEnv -> [MockEnv] shrinkMockEnv (MockEnv cis) = MockEnv . Map.fromList <$> filter (not . null) (shrinkList (const []) (Map.toList cis)) @@ -291,7 +289,7 @@ genRecipient' env uid = do ] pure $ Recipient uid route cids -genRoute :: (HasCallStack) => Gen Route +genRoute :: Gen Route genRoute = QC.elements [minBound ..] genId :: Gen (Id a) @@ -302,7 +300,7 @@ genId = do genClientId :: Gen ClientId genClientId = ClientId <$> arbitrary -genProtoAddress :: (HasCallStack) => UserId -> ClientId -> Gen Address +genProtoAddress :: UserId -> ClientId -> Gen Address genProtoAddress _addrUser _addrClient = do _addrTransport :: Transport <- QC.elements [minBound ..] arnEpId :: Text <- arbitrary @@ -374,14 +372,14 @@ dropSomeDevices = RecipientClientsSome . unsafeList1 . take numdevs <$> QC.shuffle (toList cids) -shrinkPushes :: (HasCallStack) => [Push] -> [[Push]] +shrinkPushes :: [Push] -> [[Push]] shrinkPushes = shrinkList shrinkPush where - shrinkPush :: (HasCallStack) => Push -> [Push] + shrinkPush :: Push -> [Push] shrinkPush psh = (\rcps -> psh & pushRecipients .~ rcps) <$> shrinkRecipients (psh ^. pushRecipients) - shrinkRecipients :: (HasCallStack) => Range 1 1024 (Set Recipient) -> [Range 1 1024 (Set Recipient)] + shrinkRecipients :: Range 1 1024 (Set Recipient) -> [Range 1 1024 (Set Recipient)] shrinkRecipients = fmap unsafeRange . map Set.fromList . filter (not . null) . shrinkList shrinkRecipient . Set.toList . fromRange - shrinkRecipient :: (HasCallStack) => Recipient -> [Recipient] + shrinkRecipient :: Recipient -> [Recipient] shrinkRecipient _ = [] -- | See 'Payload'. @@ -401,7 +399,7 @@ genNotifs env = fmap uniqNotifs . listOf $ do where uniqNotifs = nubBy ((==) `on` (ntfId . fst)) -shrinkNotifs :: (HasCallStack) => [(Notification, [Presence])] -> [[(Notification, [Presence])]] +shrinkNotifs :: [(Notification, [Presence])] -> [[(Notification, [Presence])]] shrinkNotifs = shrinkList (\(notif, prcs) -> (notif,) <$> shrinkList (const []) prcs) ---------------------------------------------------------------------- @@ -430,6 +428,8 @@ instance MonadPushAll MockGundeck where -- doesn't, this is good enough for testing). mpaRunWithBudget _ _ = id -- no throttling needed as long as we don't overdo it in the tests... + mpaGetClients _ = pure mempty + mpaPublishToRabbitMq _ _ = pure () instance MonadNativeTargets MockGundeck where mntgtLogErr _ = pure () @@ -529,10 +529,7 @@ handlePushNative Push {..} = do -- | From a single 'Push', store only those notifications that real Gundeck would put into -- Cassandra. -handlePushCass :: - (HasCallStack, m ~ MockGundeck) => - Push -> - m () +handlePushCass :: Push -> MockGundeck () handlePushCass Push {..} -- Condition 1: transient pushes are not put into Cassandra. | _pushTransient = pure () @@ -547,15 +544,10 @@ handlePushCass Push {..} = do forM_ cids' $ \cid -> msCassQueue %= deliver (uid, cid) _pushPayload -mockMkNotificationId :: - (HasCallStack, m ~ MockGundeck) => - m NotificationId +mockMkNotificationId :: MockGundeck NotificationId mockMkNotificationId = Id <$> getRandom -mockListAllPresences :: - (HasCallStack, m ~ MockGundeck) => - [UserId] -> - m [[Presence]] +mockListAllPresences :: [UserId] -> MockGundeck [[Presence]] mockListAllPresences uids = asks $ fmap fakePresences . filter ((`elem` uids) . fst) . allRecipients @@ -584,12 +576,11 @@ mockBulkPush notifs = do -- | persisting notification is not needed for the tests at the moment, so we do nothing here. mockStreamAdd :: - (HasCallStack, m ~ MockGundeck) => NotificationId -> List1 NotificationTarget -> Payload -> NotificationTTL -> - m () + MockGundeck () mockStreamAdd _ (toList -> targets) pay _ = forM_ targets $ \tgt -> case tgt ^. targetClients of clients@(_ : _) -> forM_ clients $ \cid -> @@ -598,11 +589,10 @@ mockStreamAdd _ (toList -> targets) pay _ = msCassQueue %= deliver (tgt ^. targetUser, ClientId 0) pay mockPushNative :: - (HasCallStack, m ~ MockGundeck) => Notification -> Priority -> [Address] -> - m () + MockGundeck () mockPushNative (ntfPayload -> payload) _ addrs = do env <- ask forM_ addrs $ \addr -> do @@ -623,10 +613,9 @@ mockLookupAddresses uid = do pure . mapMaybe (^? ciNativeAddress . _Just . _1) $ cinfos mockBulkSend :: - (HasCallStack, m ~ MockGundeck) => URI -> BulkPushRequest -> - m (URI, Either SomeException BulkPushResponse) + MockGundeck (URI, Either SomeException BulkPushResponse) mockBulkSend uri notifs = do getstatus <- mkWSStatus let flat :: [(Notification, PushTarget)] @@ -652,7 +641,7 @@ newtype Pretty a = Pretty a instance (Aeson.ToJSON a) => Show (Pretty a) where show (Pretty a) = cs $ Aeson.encodePretty a -shrinkPretty :: (HasCallStack) => (a -> [a]) -> Pretty a -> [Pretty a] +shrinkPretty :: (a -> [a]) -> Pretty a -> [Pretty a] shrinkPretty shrnk (Pretty xs) = Pretty <$> shrnk xs sublist1Of :: (HasCallStack) => [a] -> Gen (List1 a) diff --git a/services/nginz/integration-test/conf/nginz/nginx.conf b/services/nginz/integration-test/conf/nginz/nginx.conf index bfbc75ccc7c..f674540cd5d 100644 --- a/services/nginz/integration-test/conf/nginz/nginx.conf +++ b/services/nginz/integration-test/conf/nginz/nginx.conf @@ -339,6 +339,16 @@ http { proxy_pass http://brig; } + location ~* ^(/v[0-9]+)?/upgrade-personal-to-team$ { + include common_response_with_zauth.conf; + proxy_pass http://brig; + } + + location ~* ^(/v[0-9]+)?/teams/invitation/accept$ { + include common_response_with_zauth.conf; + proxy_pass http://brig; + } + # Cargohold Endpoints location ~* ^(/v[0-9]+)?/assets { diff --git a/services/spar/spar.integration.yaml b/services/spar/spar.integration.yaml index cecf8f3c43c..0ac4191c12a 100644 --- a/services/spar/spar.integration.yaml +++ b/services/spar/spar.integration.yaml @@ -1,11 +1,11 @@ saml: - version: SAML2.0 - logLevel: Warn + version: SAML2.0 + logLevel: Warn - spHost: 0.0.0.0 - spPort: 8088 - spAppUri: http://localhost:8088/ - spSsoUri: http://localhost:8088/sso + spHost: 0.0.0.0 + spPort: 8088 + spAppUri: http://localhost:8088/ + spSsoUri: http://localhost:8088/sso contacts: - type: ContactBilling @@ -36,10 +36,10 @@ cassandra: # brig, cannon, cargohold, galley, gundeck, proxy, spar. disabledAPIVersions: [] -maxttlAuthreq: 5 # seconds. don't set this too large, it is also the run time of one TTL test. -maxttlAuthresp: 7200 # seconds. do not set this to 1h or less, as that is what the mock idp wants. +maxttlAuthreq: 5 # seconds. don't set this too large, it is also the run time of one TTL test. +maxttlAuthresp: 7200 # seconds. do not set this to 1h or less, as that is what the mock idp wants. -maxScimTokens: 2 # Token limit {#RefScimToken} -richInfoLimit: 5000 # should be in sync with Brig +maxScimTokens: 8 # Token limit {#RefScimToken} +richInfoLimit: 5000 # should be in sync with Brig logNetStrings: False # log using netstrings encoding (see http://cr.yp.to/proto/netstrings.txt) diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index cbf91970c4e..ff2f3226b23 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -208,6 +208,7 @@ apiIDP = Named @"idp-get" idpGet -- get, json, captures idp id :<|> Named @"idp-get-raw" idpGetRaw -- get, raw xml, capture idp id :<|> Named @"idp-get-all" idpGetAll -- get, json + :<|> Named @"idp-create@v7" idpCreateV7 :<|> Named @"idp-create" idpCreate -- post, created :<|> Named @"idp-update" idpUpdate -- put, okay :<|> Named @"idp-delete" idpDelete -- delete, no content @@ -469,16 +470,24 @@ idpDelete mbzusr idpid (fromMaybe False -> purge) = withDebugLog "idpDelete" (co mUserIssuer <- (>>= userIssuer) <$> getAccount NoPendingInvitations uid pure $ mUserIssuer == Just idpIssuer --- | This handler only does the json parsing, and leaves all authorization checks and --- application logic to 'idpCreateXML'. +-- | We generate a new UUID for each IdP used as IdPConfig's path, thereby ensuring uniqueness. +-- +-- The human-readable name argument `mHandle` is guaranteed to be unique for historical +-- reasons. At some point, we wanted to use it to refer to IdPs in the backend API. The new +-- idea is to use the IdP ID instead, and use names only for UI purposes (`ES branch` is +-- easier to remember than `6a410704-b147-11ef-9cb0-33193c475ba4`). +-- +-- Related docs: +-- (on associating scim peers with idps) https://docs.wire.com/understand/single-sign-on/understand/main.html#associating-scim-tokens-with-saml-idps-for-authentication +-- (internal) https://wearezeta.atlassian.net/wiki/spaces/PAD/pages/1107001440/2024-03-27+scim+user+provisioning+and+saml2+sso+associating+scim+peers+and+saml2+idps idpCreate :: ( Member Random r, Member (Logger String) r, Member GalleyAccess r, Member BrigAccess r, Member ScimTokenStore r, - Member IdPRawMetadataStore r, Member IdPConfigStore r, + Member IdPRawMetadataStore r, Member (Error SparError) r ) => Maybe UserId -> @@ -487,22 +496,19 @@ idpCreate :: Maybe WireIdPAPIVersion -> Maybe (Range 1 32 Text) -> Sem r IdP -idpCreate zusr (IdPMetadataValue raw xml) = idpCreateXML zusr raw xml +idpCreate zusr (IdPMetadataValue rawIdpMetadata idpmeta) mReplaces (fromMaybe defWireIdPAPIVersion -> apiversion) mHandle = withDebugLog "idpCreateXML" (Just . show . (^. SAML.idpId)) $ do + teamid <- Brig.getZUsrCheckPerm zusr CreateUpdateDeleteIdp + GalleyAccess.assertSSOEnabled teamid + idp <- + maybe (IdPConfigStore.newHandle teamid) (pure . IdPHandle . fromRange) mHandle + >>= validateNewIdP apiversion idpmeta teamid mReplaces + IdPRawMetadataStore.store (idp ^. SAML.idpId) rawIdpMetadata + IdPConfigStore.insertConfig idp + forM_ mReplaces $ \replaces -> + IdPConfigStore.setReplacedBy (Replaced replaces) (Replacing (idp ^. SAML.idpId)) + pure idp --- | We generate a new UUID for each IdP used as IdPConfig's path, thereby ensuring uniqueness. --- --- NOTE(mangoiv): currently registering an IdP and scim token works as follows: --- - an owner creates a team with some teamId --- - the owner registers and IdP --- - the owner registers a scim token and passes the idp id along to associate --- the scim token with the IdP --- --- This doesn't support some flows we may want to support, like: (1) register --- a scim token and then associate an IdP with it; (2) have scim token and --- create an idp that is *not* associated with it; ... --- --- Related internal docs: https://wearezeta.atlassian.net/wiki/spaces/PAD/pages/1107001440/2024-03-27+scim+user+provisioning+and+saml2+sso+associating+scim+peers+and+saml2+idps -idpCreateXML :: +idpCreateV7 :: ( Member Random r, Member (Logger String) r, Member GalleyAccess r, @@ -513,42 +519,32 @@ idpCreateXML :: Member (Error SparError) r ) => Maybe UserId -> - Text -> - SAML.IdPMetadata -> + IdPMetadataInfo -> Maybe SAML.IdPId -> Maybe WireIdPAPIVersion -> Maybe (Range 1 32 Text) -> Sem r IdP -idpCreateXML zusr rawIdpMetadata idpmeta mReplaces (fromMaybe defWireIdPAPIVersion -> apiversion) mHandle = withDebugLog "idpCreateXML" (Just . show . (^. SAML.idpId)) $ do +idpCreateV7 zusr idpmeta mReplaces mApiversion mHandle = do teamid <- Brig.getZUsrCheckPerm zusr CreateUpdateDeleteIdp - GalleyAccess.assertSSOEnabled teamid assertNoScimOrNoIdP teamid - idp <- - maybe (IdPConfigStore.newHandle teamid) (pure . IdPHandle . fromRange) mHandle - >>= validateNewIdP apiversion idpmeta teamid mReplaces - IdPRawMetadataStore.store (idp ^. SAML.idpId) rawIdpMetadata - IdPConfigStore.insertConfig idp - forM_ mReplaces $ \replaces -> - IdPConfigStore.setReplacedBy (Replaced replaces) (Replacing (idp ^. SAML.idpId)) - pure idp - --- | In teams with a scim access token, only one IdP is allowed. The reason is that scim user --- data contains no information about the idp issuer, only the user name, so no valid saml --- credentials can be created. To fix this, we need to implement a way to associate scim --- tokens with IdPs. https://wearezeta.atlassian.net/browse/SQSERVICES-165 -assertNoScimOrNoIdP :: - ( Member ScimTokenStore r, - Member (Error SparError) r, - Member IdPConfigStore r - ) => - TeamId -> - Sem r () -assertNoScimOrNoIdP teamid = do - numTokens <- length <$> ScimTokenStore.lookupByTeam teamid - numIdps <- length <$> IdPConfigStore.getConfigsByTeam teamid - when (numTokens > 0 && numIdps > 0) $ - throwSparSem $ - SparProvisioningMoreThanOneIdP ScimTokenAndSecondIdpForbidden + idpCreate zusr idpmeta mReplaces mApiversion mHandle + where + -- In teams with a scim access token, only one IdP is allowed. The reason is that scim user + -- data contains no information about the idp issuer, only the user name, so no valid saml + -- credentials can be created. Only relevant for api versions 0..6. + assertNoScimOrNoIdP :: + ( Member ScimTokenStore r, + Member (Error SparError) r, + Member IdPConfigStore r + ) => + TeamId -> + Sem r () + assertNoScimOrNoIdP teamid = do + numTokens <- length <$> ScimTokenStore.lookupByTeam teamid + numIdps <- length <$> IdPConfigStore.getConfigsByTeam teamid + when (numTokens > 0 && numIdps > 0) $ + throwSparSem $ + SparProvisioningMoreThanOneIdP ScimTokenAndSecondIdpForbidden -- | 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 diff --git a/services/spar/src/Spar/Scim/Auth.hs b/services/spar/src/Spar/Scim/Auth.hs index 5bad5826054..f8b10293115 100644 --- a/services/spar/src/Spar/Scim/Auth.hs +++ b/services/spar/src/Spar/Scim/Auth.hs @@ -37,7 +37,9 @@ where import Control.Lens hiding (Strict, (.=)) import qualified Data.ByteString.Base64 as ES +import Data.Code as Code import Data.Id +import Data.Misc import qualified Data.Text.Encoding as T import Data.Text.Encoding.Error import Imports @@ -98,11 +100,11 @@ apiScimToken :: ) => ServerT APIScimToken (Sem r) apiScimToken = - Named @"auth-tokens-create@v6" createScimTokenV6 + Named @"auth-tokens-create@v7" createScimTokenV7 :<|> Named @"auth-tokens-create" createScimToken :<|> Named @"auth-tokens-put-name" updateScimTokenName :<|> Named @"auth-tokens-delete" deleteScimToken - :<|> Named @"auth-tokens-list@v6" listScimTokensV6 + :<|> Named @"auth-tokens-list@v7" listScimTokensV7 :<|> Named @"auth-tokens-list" listScimTokens updateScimTokenName :: @@ -122,7 +124,7 @@ updateScimTokenName lusr tokenId name = do -- | > docs/reference/provisioning/scim-token.md {#RefScimTokenCreate} -- -- Create a token for user's team. -createScimTokenV6 :: +createScimTokenV7 :: forall r. ( Member Random r, Member (Input Opts) r, @@ -137,18 +139,30 @@ createScimTokenV6 :: Maybe UserId -> -- | Request body CreateScimToken -> - Sem r CreateScimTokenResponseV6 -createScimTokenV6 zusr req = responseToV6 <$> createScimToken zusr req + Sem r CreateScimTokenResponseV7 +createScimTokenV7 zusr createTok = do + teamid <- guardScimTokenCreation zusr createTok.password createTok.verificationCode + idps <- IdPConfigStore.getConfigsByTeam teamid + mIdpId <- case idps of + [config] -> pure . Just $ config ^. SAML.idpId + [] -> pure Nothing + -- NB: if we ever were to allow several idps for one scim peer (which we won't), + -- 'validateScimUser' would need to be changed. currently, it relies on the association + -- map from scim to saml being n:1. + (_ : _ : _) -> throwSparSem $ E.SparProvisioningMoreThanOneIdP E.TwoIdpsAndScimTokenForbidden + + responseToV7 <$> createScimTokenUnchecked teamid Nothing createTok.description mIdpId where - responseToV6 :: CreateScimTokenResponse -> CreateScimTokenResponseV6 - responseToV6 (CreateScimTokenResponse token info) = CreateScimTokenResponseV6 token (infoToV6 info) + responseToV7 :: CreateScimTokenResponse -> CreateScimTokenResponseV7 + responseToV7 (CreateScimTokenResponse token info) = CreateScimTokenResponseV7 token (infoToV7 info) - infoToV6 :: ScimTokenInfo -> ScimTokenInfoV6 - infoToV6 ScimTokenInfo {..} = ScimTokenInfoV6 {..} + infoToV7 :: ScimTokenInfo -> ScimTokenInfoV7 + infoToV7 ScimTokenInfo {..} = ScimTokenInfoV7 {..} --- | > docs/reference/provisioning/scim-token.md {#RefScimTokenCreate} +-- | Create a token for the user's team. -- --- Create a token for user's team. +-- > docs/reference/provisioning/scim-token.md {#RefScimTokenCreate} +-- > (on associating scim peers with idps) https://docs.wire.com/understand/single-sign-on/understand/main.html#associating-scim-tokens-with-saml-idps-for-authentication createScimToken :: forall r. ( Member Random r, @@ -166,44 +180,61 @@ createScimToken :: CreateScimToken -> Sem r CreateScimTokenResponse createScimToken zusr Api.CreateScimToken {..} = do + teamid <- guardScimTokenCreation zusr password verificationCode + mIdPId <- maybe (pure Nothing) (\idpid -> IdPConfigStore.getConfig idpid $> Just idpid) idp + createScimTokenUnchecked teamid name description mIdPId + +guardScimTokenCreation :: + forall r. + ( Member (Input Opts) r, + Member GalleyAccess r, + Member BrigAccess r, + Member ScimTokenStore r, + Member (Error E.SparError) r + ) => + -- | Who is trying to create a token + Maybe UserId -> + Maybe PlainTextPassword6 -> + Maybe Code.Value -> + Sem r TeamId +guardScimTokenCreation zusr password verificationCode = do teamid <- Intra.Brig.authorizeScimTokenManagement zusr BrigAccess.ensureReAuthorised zusr password verificationCode (Just User.CreateScimToken) tokenNumber <- length <$> ScimTokenStore.lookupByTeam teamid maxTokens <- inputs maxScimTokens unless (tokenNumber < maxTokens) $ throwSparSem E.SparProvisioningTokenLimitReached - idps <- IdPConfigStore.getConfigsByTeam teamid + pure teamid - let caseOneOrNoIdP :: Maybe SAML.IdPId -> Sem r CreateScimTokenResponse - caseOneOrNoIdP midpid = do - token <- - ScimToken . T.decodeUtf8With lenientDecode . ES.encode - <$> Random.bytes 32 - tokenid <- Random.scimTokenId - -- FUTUREWORK(fisx): the fact that we're using @Now.get@ - -- here means that the 'Now' effect should not contain - -- types from saml2-web-sso. We can just use 'UTCTime' - -- there, right? - now <- Now.get - let info = - ScimTokenInfo - { stiId = tokenid, - stiTeam = teamid, - stiCreatedAt = now, - stiIdP = midpid, - stiDescr = description, - stiName = fromMaybe (idToText tokenid) name - } - ScimTokenStore.insert token info - pure $ CreateScimTokenResponse token info - - case idps of - [idp] -> caseOneOrNoIdP . Just $ idp ^. SAML.idpId - [] -> caseOneOrNoIdP Nothing - -- NB: if the following case does not result in errors, 'validateScimUser' needs to - -- be changed. currently, it relies on the fact that there is never more than one IdP. - -- https://wearezeta.atlassian.net/browse/SQSERVICES-165 - _ -> throwSparSem $ E.SparProvisioningMoreThanOneIdP E.TwoIdpsAndScimTokenForbidden +-- Create a token for user's team. +createScimTokenUnchecked :: + forall r. + ( Member Random r, + Member ScimTokenStore r, + Member Now r + ) => + TeamId -> + Maybe Text -> + Text -> + Maybe SAML.IdPId -> + Sem r CreateScimTokenResponse +createScimTokenUnchecked teamid mName desc mIdPId = do + token <- + ScimToken . T.decodeUtf8With lenientDecode . ES.encode + <$> Random.bytes 32 + tokenid <- Random.scimTokenId + now <- Now.get + let info = + ScimTokenInfo + { stiId = tokenid, + stiTeam = teamid, + stiCreatedAt = now, + stiIdP = mIdPId, + stiDescr = desc, + stiName = fromMaybe (idToText tokenid) mName + } + ScimTokenStore.insert token info + pure $ CreateScimTokenResponse token info -- | > docs/reference/provisioning/scim-token.md {#RefScimTokenDelete} -- @@ -223,7 +254,7 @@ deleteScimToken zusr tokenid = do ScimTokenStore.delete teamid tokenid pure NoContent -listScimTokensV6 :: +listScimTokensV7 :: ( Member GalleyAccess r, Member BrigAccess r, Member ScimTokenStore r, @@ -231,14 +262,14 @@ listScimTokensV6 :: ) => -- | Who is trying to list tokens Maybe UserId -> - Sem r ScimTokenListV6 -listScimTokensV6 zusr = toV6 <$> listScimTokens zusr + Sem r ScimTokenListV7 +listScimTokensV7 zusr = toV7 <$> listScimTokens zusr where - toV6 :: ScimTokenList -> ScimTokenListV6 - toV6 (ScimTokenList tokens) = ScimTokenListV6 $ map infoToV6 tokens + toV7 :: ScimTokenList -> ScimTokenListV7 + toV7 (ScimTokenList tokens) = ScimTokenListV7 $ map infoToV7 tokens - infoToV6 :: ScimTokenInfo -> ScimTokenInfoV6 - infoToV6 ScimTokenInfo {..} = ScimTokenInfoV6 {..} + infoToV7 :: ScimTokenInfo -> ScimTokenInfoV7 + infoToV7 ScimTokenInfo {..} = ScimTokenInfoV7 {..} -- | > docs/reference/provisioning/scim-token.md {#RefScimTokenList} -- diff --git a/services/spar/test-integration/Test/Spar/Scim/AuthSpec.hs b/services/spar/test-integration/Test/Spar/Scim/AuthSpec.hs index eb285a5e61b..4e5f5b68aba 100644 --- a/services/spar/test-integration/Test/Spar/Scim/AuthSpec.hs +++ b/services/spar/test-integration/Test/Spar/Scim/AuthSpec.hs @@ -93,14 +93,15 @@ testCreateToken = do -- Create a token (owner, _tid) <- call $ createUserWithTeam (env ^. teBrig) (env ^. teGalley) _ <- registerTestIdP owner - CreateScimTokenResponse token _ <- + CreateScimTokenResponseV7 token _ <- createToken owner CreateScimToken { description = "testCreateToken", password = Just defPassword, verificationCode = Nothing, - name = Nothing + name = Nothing, + idp = Nothing } -- Try to do @GET /Users@ and check that it succeeds let fltr = filterBy "externalId" "67c196a0-cd0e-11ea-93c7-ef550ee48502" @@ -121,18 +122,18 @@ testCreateTokenWithVerificationCode = do user <- getUserBrig owner let email = fromMaybe undefined (userEmail =<< user) - let reqMissingCode = CreateScimToken "testCreateToken" (Just defPassword) Nothing Nothing + let reqMissingCode = CreateScimToken "testCreateToken" (Just defPassword) Nothing Nothing Nothing createTokenFailsWith owner reqMissingCode 403 "code-authentication-required" void $ requestVerificationCode (env ^. teBrig) email Public.CreateScimToken let wrongCode = Code.Value $ unsafeRange (fromRight undefined (validate "123456")) - let reqWrongCode = CreateScimToken "testCreateToken" (Just defPassword) (Just wrongCode) Nothing + let reqWrongCode = CreateScimToken "testCreateToken" (Just defPassword) (Just wrongCode) Nothing Nothing createTokenFailsWith owner reqWrongCode 403 "code-authentication-failed" void $ retryNUntil 6 ((==) 200 . statusCode) $ requestVerificationCode (env ^. teBrig) email Public.CreateScimToken code <- getVerificationCode (env ^. teBrig) owner Public.CreateScimToken - let reqWithCode = CreateScimToken "testCreateToken" (Just defPassword) (Just code) Nothing - CreateScimTokenResponse token _ <- createToken owner reqWithCode + let reqWithCode = CreateScimToken "testCreateToken" (Just defPassword) (Just code) Nothing Nothing + CreateScimTokenResponseV7 token _ <- createToken owner reqWithCode -- Try to do @GET /Users@ and check that it succeeds let fltr = filterBy "externalId" "67c196a0-cd0e-11ea-93c7-ef550ee48502" @@ -174,32 +175,25 @@ testTokenLimit = do -- Create two tokens (owner, _teamId) <- call $ createUserWithTeam (env ^. teBrig) (env ^. teGalley) _ <- registerTestIdP owner - _ <- - createToken - owner - CreateScimToken - { description = "testTokenLimit / #1", - password = Just defPassword, - verificationCode = Nothing, - name = Nothing - } - _ <- + replicateM_ 8 $ createToken owner CreateScimToken - { description = "testTokenLimit / #2", + { description = "testTokenLimit / #1-8", password = Just defPassword, verificationCode = Nothing, - name = Nothing + name = Nothing, + idp = Nothing } - -- Try to create the third token and see that it fails + -- Try to create the ninth token and see that it fails createToken_ owner CreateScimToken - { description = "testTokenLimit / #3", + { description = "testTokenLimit / #8", password = Just defPassword, verificationCode = Nothing, - name = Nothing + name = Nothing, + idp = Nothing } (env ^. teSpar) !!! checkErr 403 (Just "token-limit-reached") @@ -218,13 +212,13 @@ testNumIdPs = do SAML.SampleIdP metadata _ _ _ <- SAML.makeSampleIdPMetadata void $ call $ Util.callIdpCreate apiversion spar (Just owner) metadata - createToken owner (CreateScimToken "eins" (Just defPassword) Nothing Nothing) + createToken owner (CreateScimToken "eins" (Just defPassword) Nothing Nothing Nothing) >>= deleteToken owner . (.stiId) . (.info) addSomeIdP - createToken owner (CreateScimToken "zwei" (Just defPassword) Nothing Nothing) + createToken owner (CreateScimToken "zwei" (Just defPassword) Nothing Nothing Nothing) >>= deleteToken owner . (.stiId) . (.info) addSomeIdP - createToken_ owner (CreateScimToken "drei" (Just defPassword) Nothing Nothing) (env ^. teSpar) + createToken_ owner (CreateScimToken "drei" (Just defPassword) Nothing Nothing Nothing) (env ^. teSpar) !!! checkErr 400 (Just "more-than-one-idp") -- @SF.Provisioning @TSFI.RESTfulAPI @S2 @@ -251,7 +245,8 @@ testCreateTokenAuthorizesOnlyAdmins = do { description = "testCreateToken", password = Just defPassword, verificationCode = Nothing, - name = Nothing + name = Nothing, + idp = Nothing } (env ^. teSpar) @@ -280,7 +275,8 @@ testCreateTokenRequiresPassword = do { description = "testCreateTokenRequiresPassword", password = Nothing, verificationCode = Nothing, - name = Nothing + name = Nothing, + idp = Nothing } (env ^. teSpar) !!! checkErr 403 (Just "access-denied") @@ -291,7 +287,8 @@ testCreateTokenRequiresPassword = do { description = "testCreateTokenRequiresPassword", password = Just (plainTextPassword6Unsafe "wrong password"), verificationCode = Nothing, - name = Nothing + name = Nothing, + idp = Nothing } (env ^. teSpar) !!! checkErr 403 (Just "access-denied") @@ -319,7 +316,8 @@ testListTokens = do { description = "testListTokens / #1", password = Just defPassword, verificationCode = Nothing, - name = Nothing + name = Nothing, + idp = Nothing } _ <- createToken @@ -328,7 +326,8 @@ testListTokens = do { description = "testListTokens / #2", password = Just defPassword, verificationCode = Nothing, - name = Nothing + name = Nothing, + idp = Nothing } -- Check that the token is on the list list <- (.scimTokenListTokens) <$> listTokens owner @@ -423,14 +422,15 @@ testDeletedTokensAreUnusable = do -- Create a token (owner, _teamId) <- call $ createUserWithTeam (env ^. teBrig) (env ^. teGalley) _ <- registerTestIdP owner - CreateScimTokenResponse token tokenInfo <- + CreateScimTokenResponseV7 token tokenInfo <- createToken owner CreateScimToken { description = "testDeletedTokensAreUnusable", password = Just defPassword, verificationCode = Nothing, - name = Nothing + name = Nothing, + idp = Nothing } -- An operation with the token should succeed let fltr = filterBy "externalId" "67c196a0-cd0e-11ea-93c7-ef550ee48502" @@ -449,14 +449,15 @@ testDeletedTokensAreUnlistable = do env <- ask (owner, _teamId) <- call $ createUserWithTeam (env ^. teBrig) (env ^. teGalley) _ <- registerTestIdP owner - CreateScimTokenResponse _ tokenInfo <- + CreateScimTokenResponseV7 _ tokenInfo <- createToken owner CreateScimToken { description = "testDeletedTokensAreUnlistable", password = Just defPassword, verificationCode = Nothing, - name = Nothing + name = Nothing, + idp = Nothing } -- Delete the token deleteToken owner tokenInfo.stiId diff --git a/services/spar/test-integration/Util/Core.hs b/services/spar/test-integration/Util/Core.hs index 6d92d56e0df..c033cc5492a 100644 --- a/services/spar/test-integration/Util/Core.hs +++ b/services/spar/test-integration/Util/Core.hs @@ -48,6 +48,7 @@ module Util.Core call, endpointToReq, mkVersionedRequest, + versioned, -- * Other randomEmail, @@ -1263,7 +1264,7 @@ stdInvitationRequest = stdInvitationRequest' Nothing Nothing -- | copied from brig integration tests stdInvitationRequest' :: Maybe User.Locale -> Maybe Role -> EmailAddress -> TeamInvitation.InvitationRequest stdInvitationRequest' loc role email = - TeamInvitation.InvitationRequest loc role Nothing email + TeamInvitation.InvitationRequest loc role Nothing email True setRandomHandleBrig :: (HasCallStack) => UserId -> TestSpar () setRandomHandleBrig uid = do @@ -1334,3 +1335,23 @@ getIdPByIssuer issuer tid = do runSpar $ case idpApiVersion of WireIdPAPIV1 -> IdPConfigStore.getIdPByIssuerV1Maybe issuer WireIdPAPIV2 -> IdPConfigStore.getIdPByIssuerV2Maybe issuer tid + +-- | Note: Apply this function last when composing (Request -> Request) functions +versioned :: ByteString -> Request -> Request +versioned newVersion r = r {HTTP.path = setVersion newVersion (HTTP.path r)} + where + setVersion :: ByteString -> ByteString -> ByteString + setVersion v p = + let p' = removeSlash' p + in v <> "/" <> fromMaybe p' (removeVersionPrefix p') + removeSlash' :: ByteString -> ByteString + removeSlash' s = case B8.uncons s of + Just ('/', s') -> s' + _ -> s + + removeVersionPrefix :: ByteString -> Maybe ByteString + removeVersionPrefix bs = do + let (x, s) = B8.splitAt 1 bs + guard (x == B8.pack "v") + (_, s') <- B8.readInteger s + pure (B8.tail s') diff --git a/services/spar/test-integration/Util/Scim.hs b/services/spar/test-integration/Util/Scim.hs index d95bde89583..acfad1fe0a2 100644 --- a/services/spar/test-integration/Util/Scim.hs +++ b/services/spar/test-integration/Util/Scim.hs @@ -338,7 +338,7 @@ createToken :: (HasCallStack) => UserId -> CreateScimToken -> - TestSpar CreateScimTokenResponse + TestSpar CreateScimTokenResponseV7 createToken zusr payload = do env <- ask r <- @@ -536,7 +536,8 @@ createToken_ :: createToken_ userid payload spar_ = do call . post - $ ( spar_ + $ ( versioned "v6" + . spar_ . paths ["scim", "auth-tokens"] . zUser userid . contentJson diff --git a/services/spar/test/Arbitrary.hs b/services/spar/test/Arbitrary.hs index b9d3f0de56a..812ef45da1b 100644 --- a/services/spar/test/Arbitrary.hs +++ b/services/spar/test/Arbitrary.hs @@ -50,8 +50,8 @@ instance Arbitrary ScimTokenHash where instance Arbitrary ScimTokenList where arbitrary = ScimTokenList <$> arbitrary -instance Arbitrary ScimTokenListV6 where - arbitrary = ScimTokenListV6 <$> arbitrary +instance Arbitrary ScimTokenListV7 where + arbitrary = ScimTokenListV7 <$> arbitrary instance Arbitrary ScimTokenName where arbitrary = ScimTokenName <$> arbitrary diff --git a/tools/stern/src/Stern/Intra.hs b/tools/stern/src/Stern/Intra.hs index a5e17507ff2..3c0ad708181 100644 --- a/tools/stern/src/Stern/Intra.hs +++ b/tools/stern/src/Stern/Intra.hs @@ -819,7 +819,7 @@ getUserClients uid = do . expect2xx ) info $ msg ("Response" ++ show r) - let resultOrError :: Either String [Versioned 'V6 Client] = responseJsonEither r + let resultOrError :: Either String [Versioned 'V7 Client] = responseJsonEither r case resultOrError of Left e -> do Log.err $ msg ("Error parsing client response: " ++ e) diff --git a/tools/stern/test/integration/Util.hs b/tools/stern/test/integration/Util.hs index d151434f164..41608bc2d44 100644 --- a/tools/stern/test/integration/Util.hs +++ b/tools/stern/test/integration/Util.hs @@ -150,7 +150,7 @@ addUserToTeamWithRole' :: (HasCallStack) => Maybe Role -> UserId -> TeamId -> Te addUserToTeamWithRole' role inviter tid = do brig <- view tsBrig email <- randomEmail - let invite = InvitationRequest Nothing role Nothing email + let invite = InvitationRequest Nothing role Nothing email True invResponse <- postInvitation tid inviter invite inv <- responseJsonError invResponse inviteeCode <- getInvitationCode tid inv.invitationId diff --git a/weeder.toml b/weeder.toml index 64669d11e89..6cfce9d5723 100644 --- a/weeder.toml +++ b/weeder.toml @@ -172,6 +172,7 @@ roots = [ # may of the entries here are about general-purpose module "^Web.Scim.Client.patchUser", "^Web.Scim.Client.postGroup", "^Web.Scim.Client.postUser", + "^Web.Scim.Client.putUser", "^Web.Scim.Client.putGroup", "^Web.Scim.Client.resourceTypes", "^Web.Scim.Client.schema",